117 Commits

Author SHA1 Message Date
Hammed Abass
fc1d27bf45 Merge pull request #33 from sudosylabs/feat/release-test-flight
feat(apple): stable iOS/macOS release + macOS direct-download channel
2026-07-27 16:25:55 +02:00
4730554c2c refactor(apple): make InvitationError typed and localize NFC prompts
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.
2026-07-27 16:07:19 +02:00
cbb535d998 chore(apple): stop tracking RELEASE-MACOS.md
Keep the macOS release notes local-only; remove from the index and ignore
so the working copy stays on disk without being committed.
2026-07-27 16:05:35 +02:00
a0ebd7c71b fix(apple): restore macOS approval modal and sandboxed file sharing
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.
2026-07-27 15:01:01 +02:00
cc194f6a7b feat(apple): add direct-download macOS channel (notarized DMG + Sparkle + Homebrew)
Add a second macOS shipping channel alongside the App Store build:

- New VniDropDirect target (Release-Direct config) sharing VniDrop's sources via
  an AppBase target template; links Sparkle behind the DIRECT_DISTRIBUTION flag so
  the App Store binary never bundles a self-updater. arm64-only (core is arm64).
- Sparkle updater (SparkleUpdater.swift) + "Check for Updates" menu, compiled only
  under DIRECT_DISTRIBUTION; Info.plist SUFeedURL points at the GitHub Release
  /latest/download/appcast.xml, non-sandboxed entitlements for Developer ID.
- build-dmg.sh (archive → Developer ID export → DMG → sign → notarize → staple),
  generate-appcast.sh, and ExportOptions-DeveloperID.plist.
- apple-release.yml: on tag v*.*.*, build/notarize the DMG, publish the GitHub
  Release with appcast, and push the Homebrew cask to sudosylabs/homebrew-vnidrop.
  apple.yml gains a PR compile-check of the direct target.
- CFBundleVersion is stamped at build time as a UTC YYMMDD.HHMM timestamp for both
  channels, replacing the hand-maintained build number.
- Docs (RELEASE-MACOS.md, README), cask template + tap README, localized
  updates_check string, Makefile targets, gitignore for dist/ artifacts.
2026-07-27 14:31:36 +02:00
8de190a36e chore(apple): update Icon Composer app icon definition 2026-07-27 12:19:13 +02:00
73bc87d3d1 fix(apple): use TAG NFC reader format for iOS 26 SDK
App Store upload with the iOS 26 SDK rejects the NDEF value (error 90778
"NDEF is disallowed") and requires TAG. NFCNDEFReaderSession keeps working
under the TAG entitlement, so no code changes are needed.
2026-07-27 10:27:22 +02:00
2166aa9ce4 build(apple): ship TestFlight build 7 as Release
Set CURRENT_PROJECT_VERSION to 7 for the next TestFlight upload, and pin the
scheme's Archive/Profile actions to the Release configuration so Product →
Archive can't pick up Debug.
2026-07-27 10:21:54 +02:00
22b93ce94e feat(apple): keep iOS transfers alive in the background
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.
2026-07-27 10:02:31 +02:00
31ba3f40b2 test(shared): resolve UI copy from resources 2026-07-25 19:46:34 +02:00
f3124371ee fix(apple): resolve App Store validation errors
- Info.plist: drop unused `fetch`/`processing` background modes (no
  BGTaskScheduler implementation exists, which they would require); keep
  remote-notification.
- Info.plist: set ITSAppUsesNonExemptEncryption=false — the app's standard
  end-to-end encryption qualifies for the mass-market export exemption, so no
  compliance code is required.
- project.yml: emit dwarf-with-dsym for Release so archive symbol upload works.

The remaining upload errors (NFC "NDEF is disallowed", Unsupported SDK) are
artifacts of building with a beta Xcode/SDK 27 and clear when archiving with a
release/RC Xcode; NFCNDEFReaderSession legitimately requires the NDEF format
entitlement, so it is kept as-is.
2026-07-25 19:35:59 +02:00
ea2f8b1cc7 feat(apple): adopt Icon Composer app icon
Replace the legacy AppIcon.appiconset with an Icon Composer AppIcon.icon
bundle in the target's resources. ASSETCATALOG_COMPILER_APPICON_NAME already
points at "AppIcon"; the .icon back-deploys to the iOS 18.2 target.
2026-07-25 16:24:04 +02:00
9b8d66f97d chore(packaging): add Apple App Store design source via Git LFS
Track the Affinity design master (AppStore.af) with Git LFS to keep repo
history lean, and ignore the large exported JPG screenshots (regenerated from
the source) plus macOS/editor junk.
2026-07-25 16:06:44 +02:00
a8a168ffde chore(apple): declare non-exempt encryption use
Add ITSAppUsesNonExemptEncryption=YES to Info.plist so the export-compliance
question is answered once (the app uses standard end-to-end encryption via
iroh). Avoids being re-prompted on every TestFlight/App Store upload.
2026-07-25 16:06:44 +02:00
516c4ace84 docs: set Apache license copyright to VniDrop
Fill in the Apache 2.0 copyright placeholder with "Copyright 2026 VniDrop",
matching the App Store copyright field.
2026-07-25 16:06:43 +02:00
Hammed Abass
c7657c4b37 Merge pull request #31 from sudosylabs/feat/apple-typed-resources-and-fixes
Apple: typed resources and UX fixes
2026-07-24 21:16:56 +02:00
b8e8dd8644 test(core): wait for delivery event visibility 2026-07-24 21:05:37 +02:00
83b66c5eb7 Merge remote-tracking branch 'origin/feat/apple-typed-resources-and-fixes' 2026-07-24 20:52:36 +02:00
81e84b14f8 fix(shared): align action icons and storage refresh 2026-07-24 20:24:23 +02:00
c81cb7c8b6 feat(shared): align non-Apple UX with Apple 2026-07-24 19:58:33 +02:00
319af6f2de fix(l10n): align notification/storage descriptions with KMP behavior
The merge kept this branch's reworded notifications_description and
storage_delete_transfers_description over master's, but the merged KMP code is
master's, so its FoundationComposeTest assertions (and the shipped KMP copy) expect
master's wording. Restore both to master's committed text (pulling the storage one
from master's XML, since master's own strings.json was stale for it). Verified the
two failing KMP Compose tests pass locally.
2026-07-24 19:58:29 +02:00
3abd4d0cfd build(apple): flag raw string literals in SwiftUI initializers
The typed-resource rules missed a bare string literal passed as the leading arg of
a view initializer (e.g. Label("send_stop_sharing", …)), which is an implicit
LocalizedStringKey. Add a rule covering Text/Label/Button/Section/Picker/etc.
(empty labels allowed). Fixes the two dynamic-content Text sites it surfaced by
switching them to Text(verbatim:).
2026-07-24 19:42:40 +02:00
b66cb8c1f1 fix(l10n): restore storage_clearing_transfer_cache dropped in merge
Another key master referenced from Kotlin but kept only in the generated Compose
XML, so regeneration dropped it. Verified exhaustively this time: every
Res.string.* reference in shared/src/commonMain/kotlin now resolves against the
regenerated values/strings.xml, so no further keys are missing.
2026-07-24 19:28:52 +02:00
98da43b122 docs: make strings.json the documented source of truth for l10n
Record in AGENTS.md that localization/strings.json is the single source of truth
and the KMP XML + Apple xcstrings/L10n.swift are generated by the loc CLI and must
never be hand-edited — a key present only in a generated file is dropped on the
next regeneration (which is how the transfer-cache strings were lost in the merge).
2026-07-24 19:02:02 +02:00
7d3f1b9862 fix(l10n): add the transfer-cache-clear strings dropped in merge
Master referenced storage_clear_transfer_cache(_description) and
storage_transfer_cache_cleared from Kotlin but never added them to strings.json —
they lived only in the committed Compose XML. Regenerating l10n from the merged
strings.json dropped them, breaking the shared-kmp build. Add them (kmp target,
all 9 languages, text carried over from master) so generation restores them.
2026-07-24 19:02:02 +02:00
83ddf9f059 ci(apple): install SwiftLint for the required lint build phase
The VniDrop target's SwiftLint pre-build phase is required (fails if missing), so
the Apple CI job must have SwiftLint available. Add a brew install step.
2026-07-24 18:52:26 +02:00
f513a6118e feat(apple): notify the sender when a receiver's delivery fails
plannedReceiverNotifications only fired for completed receivers, so a failed
delivery produced no notification. Add a receiverFailed kind wired through the
planner, id, and deliver paths, with localized notifications_receiver_failed_*
strings and a unit test.
2026-07-24 18:45:02 +02:00
35f06a0b6b fix(apple): localize receiver failure reasons
The receiver row showed the core's raw reason code (e.g. destination_exists),
breaking the never-expose-raw-reason-blobs rule. Map the core reason codes to the
existing L10n.Error.* messages via receiverReasonUiText, with a generic fallback so
a raw code is never surfaced.
2026-07-24 18:45:02 +02:00
b65bac021f build(apple): enforce typed resources with SwiftLint
Add a focused .swiftlint.yml (custom rules only, no default style noise) flagging
raw String(localized:) / LocalizedStringKey / systemName|systemImage literals, and
wire it as a required pre-build phase that fails the build if SwiftLint is missing
(brew install swiftlint). The phase prepends the Homebrew bin dirs since Xcode runs
scripts with a minimal PATH. Runs clean on the current tree (0 violations).
2026-07-24 18:32:15 +02:00
d2924f7ce6 fix(apple): focus the running instance on notification tap
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.
2026-07-24 18:32:15 +02:00
3042005226 refactor(apple): type the merged relay/network resources
Convert master's raw-string localization keys and SF Symbols in the new
relay/network code to typed accessors, matching this branch's typed-resources
convention: relay mode labels/descriptions, NetworkSettings strings, the endpoint
id and relay-validation messages (now typed L10n functions), and SF Symbols via
SFSafeSymbols. Retype the model's relayApplyErrorKey from a raw String key to
String.LocalizationValue so no loose key literals remain in the settings layer.
2026-07-24 18:15:36 +02:00
9b15a388d8 fix(apple): polish the merged Network settings
Move the relay-mode picker's Network title into a Section header (the inline
picker label rendered as a stray row on iOS) and hide the picker label. Use a
verbatim prompt for the relay URL placeholder so macOS stops markdown-linkifying
the URL-shaped text into a purple link.
2026-07-24 17:54:59 +02:00
c23f7916bb Merge origin/master into feat/apple-typed-resources-and-fixes
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).
2026-07-24 17:48:10 +02:00
Hammed Abass
0f8f89641a Merge pull request #32 from sudosylabs/feat/custom-relays
feat(network): add custom relay configuration and transfer controls
2026-07-24 17:09:16 +02:00
b724c1540f fix(shared): derive path names in Rust 2026-07-24 16:52:10 +02:00
1d049d08f2 fix(storage): release core before clearing cache 2026-07-24 16:02:46 +02:00
aab5f243ca fix(apple): treat a completed receiver event as terminal
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.
2026-07-24 16:02:06 +02:00
065d57e896 refactor(apple): redesign the composer source buttons
Replace the bare text links under Start sharing with an even row of bordered,
icon-led buttons (Change files, Choose folder, plus Clear on wider layouts).
Single-line labels keep them equal height, and a neutral tint keeps them quiet so
the purple Start sharing reads as the primary action.
2026-07-24 15:14:21 +02:00
c7ebaee15b feat(apple): add context menus to Send and Receive rows
Send rows get a context menu that acts inline without navigating: Share opens the
share panel (QR + delivery) over the list via a dedicated sheet host, Stop sharing
(active shares) and Delete transfer run in place, the latter through a new id-based
SendModel.deleteTransfer and a list-level confirmation alert. Receive rows get a
Delete action mirroring swipe-to-delete (handy on macOS).
2026-07-24 15:08:37 +02:00
425500ecf2 fix(core): report receiver failures to sender 2026-07-24 14:52:09 +02:00
30dfabf8e7 refactor(apple): move share to the toolbar and delete to the bottom
Put a share icon in the transfer-details toolbar (opening the QR/share panel) in
place of the delete button, drop the now-redundant Share row from the list, and
move Delete transfer into the bottom section alongside Stop sharing.
2026-07-24 14:46:44 +02:00
4bd51106e8 feat(apple): cover the window while the core boots
The core initializes asynchronously at launch, so for a moment the transfer lists
look empty and the app feels stalled. Show a full-window overlay (centered spinner
+ "Starting…") as the top layer of the root stack while coreState.isInitialized is
false; it fades out once the core is ready.
2026-07-24 14:41:37 +02:00
6e2c8b4b2d feat(apple): open the share panel right after creating a transfer
After Start sharing succeeds, jump straight to the new transfer's share panel
(QR code + delivery actions) instead of returning to the list and making the user
drill in via the row and the share row. Refresh first so the transfer exists in
state before selecting it; the share panel already handles the brief window before
the ticket is ready.
2026-07-24 14:18:22 +02:00
e22e395efc feat(apple): streamline the Storage screen and fix stuck usage
Redesign the Storage screen for clarity: an "On this device" usage header with a
manual Refresh control, symbol-led action buttons, and a caption under each action
spelling out exactly what it does (Free up space = temp + trash, non-destructive;
Delete all transfers = clears history + cached share content, keeps received
files).

Fix the summary sticking on "Calculating…": it loaded only on .onAppear and bailed
when opened before the core finished its async launch, leaving the loading branch
showing with nothing running (only a manual refresh recovered it). loadStorageUsage
now waits for the core to become ready before reading usage, distinguishes a real
failure (retry) from loading, loads via .task, and can be refreshed on demand. Use
plain button styling with explicit tints so pressing an action no longer flips the
label to the white selection highlight.
2026-07-24 14:07:31 +02:00
677c3ce47f feat(apple): add a Free up space action to reclaim leaked storage
Delete all transfers only clears core records, and the blob-store cache is
reclaimed by the core's own timer. Neither touches the app's temporary directory
(leftover picker/staging copies — hundreds of MB on macOS) or the stray .Trash
folders that accumulate in app-owned directories and can't be removed via
Files/Finder. Add a non-destructive Free up space button that empties the temp
directory and removes .Trash folders under the core data dir (and, on iOS, the
fixed Documents receive folder), reporting the bytes reclaimed. Guarded against
running while a transfer is in flight; never touches received files, the core
database, or user-chosen macOS receive folders.
2026-07-24 13:46:27 +02:00
20597e6e88 fix(apple): enforce a single window on macOS and iPadOS
macOS uses a single-instance `Window` scene instead of `WindowGroup`, which
otherwise lets the app open multiple windows (via ⌘N). iPadOS sets
`UIApplicationSupportsMultipleScenes = false` to block a second scene via Stage
Manager / split view. (`LSMultipleInstancesProhibited` only blocks a second
process, not a second window.)
2026-07-24 13:19:26 +02:00
42f569dcf0 feat(apple): allow only one macOS app instance
Set LSMultipleInstancesProhibited so re-launching VniDrop (or opening a
vnidrop URL) activates the running instance instead of spawning a second
copy. iOS ignores the key — it's single-instance already.
2026-07-24 12:27:09 +02:00
8b31c0fe78 feat(apple): use the brand purple as the app-wide accent color
The macOS sidebar selection and item icons rendered in the system default
blue because the app had no global accent color — SwiftUI's `.tint` doesn't
reach the AppKit-backed sidebar. Add an AccentColor asset (the exact sRGB of
VniDropColors.brandPurple) and wire ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME
so the accent applies at the OS level everywhere, including the sidebar.
2026-07-24 00:37:49 +02:00
c9ef40f00f refactor(apple): tidy the receive-folder preference row
The "Save received transfers to" section was a gray folder label that read
like a disabled field, stacked above two full-width buttons. Replace it with
the standard macOS "label · value · inline action" row: a folder icon + the
current folder name with a trailing "Choose folder" button, long names
truncated in the middle. "Use default" now shows only when a custom folder is
actually set (hidden when already on the default, where it'd be a no-op).
2026-07-24 00:28:34 +02:00
0448137d84 fix(core): abort send when provider stream closes 2026-07-24 00:11:58 +02:00
6180cb95ce fix(apple): stop delete confirmation re-presenting on macOS
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.
2026-07-23 23:43:05 +02:00
2b2fe93293 build(apple): enable dead-code stripping and missing-localizability analyzer
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
2026-07-23 23:30:03 +02:00
3469b122c2 feat(apple): local notifications for transfer lifecycle events
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".
2026-07-23 23:29:40 +02:00
4074f4bee8 feat(storage): clear inactive transfer cache 2026-07-23 22:22:30 +02:00
7cc0e825f6 fix(settings): confirm deleting all transfers 2026-07-23 19:16:11 +02:00
efb3c474d1 feat(settings): align relay and storage controls 2026-07-23 19:03:19 +02:00
0e0d43bc4d build(apple): stop tracking generated localization outputs
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.
2026-07-23 18:48:03 +02:00
081b59815c fix(apple): make receive-cancel actually cancel the transfer
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.
2026-07-23 18:30:55 +02:00
3c8267adc5 refactor(apple): type core event phase/kind/direction as enums
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.
2026-07-23 18:00:29 +02:00
08e61c57af feat(l10n): replace legacy %@ format keys with semantic template keys
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.
2026-07-23 17:50:57 +02:00
1563bf80d6 feat(apple): type-safe SF Symbols via SFSafeSymbols
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.
2026-07-23 17:40:07 +02:00
ef42875ddb feat(apple): generate type-safe L10n accessors and migrate all key literals
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.
2026-07-23 17:12:43 +02:00
7592d49a59 fix(deps): update iroh to 1.0.3 2026-07-23 16:50:27 +02:00
a0bcc5dbff feat(network): add relay connection policies 2026-07-23 15:14:03 +02:00
cbace73908 feat(network): support custom relay servers
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.
2026-07-23 14:27:40 +02:00
Hammed Abass
5939489432 Merge pull request #29 from sudosylabs/feat/win32-screenshots
fix(desktop): use native Windows file and folder dialogs
2026-07-22 22:06:20 +02:00
Hammed Abass
90a06e9151 Merge pull request #28 from sudosylabs/fix/transfer-completion-progress
fix(transfer): finalize delivery completion
2026-07-22 21:54:57 +02:00
6cf6644c09 fix(desktop): use native Windows file dialogs 2026-07-22 21:49:49 +02:00
f0b06ad1cf fix(transfer): finalize delivery completion 2026-07-22 21:34:33 +02:00
e7c70c9314 docs: add Microsoft Store screenshots 2026-07-22 21:21:58 +02:00
Hammed Abass
ad9ffd63f5 Merge pull request #27 from sudosylabs/fix/typed-error-propagation
fix(core): preserve typed transfer failures
2026-07-22 21:00:06 +02:00
82fb549576 fix(core): preserve typed transfer failures 2026-07-22 20:43:15 +02:00
Hammed Abass
c6e59bed26 Merge pull request #26 from sudosylabs/feat/native-platform-icons
feat(ui): add native platform icon sets
2026-07-22 19:58:36 +02:00
58c470b279 fix(ci): isolate Linux Gradle caches 2026-07-22 19:02:01 +02:00
4e2777b917 feat(ui): add native platform icon sets 2026-07-22 18:47:23 +02:00
Hammed Abass
b027177ad3 Merge pull request #25 from sudosylabs/feat/storage-accounting
fix(storage): reclaim transfer cache and track received files
2026-07-22 17:33:25 +02:00
Hammed Abass
6388d42ec1 Merge pull request #24 from sudosylabs/feat/native-platform-ui
feat(ui): add native platform experiences
2026-07-22 16:35:46 +02:00
b46c5e7d72 fix(storage): reclaim transfer cache and track received files 2026-07-22 16:03:37 +02:00
be7a61e948 feat(desktop): integrate native Windows titlebar 2026-07-22 15:49:41 +02:00
0e97c014a1 feat(desktop): add native Windows window controller 2026-07-22 15:49:11 +02:00
0ab076e5b2 feat(ui): support native desktop backdrops 2026-07-22 15:48:16 +02:00
a0d352895e feat(ui): align Android empty states 2026-07-22 12:26:35 +02:00
cf7734fb42 feat(desktop): polish Linux native experience 2026-07-22 11:23:10 +02:00
627c205853 feat(ui): adapt presentation to each platform 2026-07-22 10:04:32 +02:00
Hammed Abass
4feff29c74 Merge pull request #23 from sudosylabs/feat/remove-apple-kmp-targets
refactor(platform): move Apple apps out of KMP
2026-07-21 18:46:21 +02:00
873ec6fd94 fix(diagnostics): stabilize pending crash ordering 2026-07-21 18:23:50 +02:00
a8a873c83d build: unify project development commands 2026-07-21 18:07:06 +02:00
9e8564e013 refactor(platform): remove Apple targets from KMP 2026-07-21 17:09:59 +02:00
Hammed Abass
d8eaf78997 Merge pull request #21 from sudosylabs/feat/localize
Single-source localization + 8 new languages
2026-07-21 16:40:11 +02:00
8c1ce16c0e Merge branch 'master' into feat/localize
merge(master): Fix for fileImporter before iOS/macOS 27
2026-07-20 18:25:06 +02:00
Hammed Abass
419c35d6b5 Merge pull request #22 from sudosylabs/feat/fix-file-importer
fix(apple): make "Choose files" work on iOS/macOS < 27
2026-07-20 18:21:57 +02:00
0011dfa175 fix(apple): make "Choose files" work on iOS/macOS < 27
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.
2026-07-20 18:11:35 +02:00
276ee9f974 i18n(ru): avoid seeding terminology
Replace "раздача"/"раздаваться" (literally "seeding") with neutral
"общий доступ"/"отправка" phrasing across the Russian strings, to avoid
BitTorrent connotations for App Store review.
2026-07-20 12:40:57 +02:00
8178296d92 feat(l10n): add Russian translations
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.
2026-07-20 11:38:12 +02:00
0efde72414 feat(l10n): add Dutch translations
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.
2026-07-20 11:34:21 +02:00
48a523371c feat(l10n): add Polish translations
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.
2026-07-20 11:29:58 +02:00
674e59f8cc feat(l10n): add European Portuguese translations
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.
2026-07-20 11:25:11 +02:00
0309cfd649 feat(l10n): add German translations
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.
2026-07-20 11:20:32 +02:00
ea07f5661d feat(l10n): add Italian translations
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.
2026-07-20 11:15:54 +02:00
141be2e55a feat(l10n): add Spanish translations
Translate all 253 user-facing strings to Spanish (formal "usted"), including
the transfer_file_count plural. Generates values-es/strings.xml for KMP, es
localizations in the Apple catalog, and adds es to CFBundleLocalizations.
2026-07-20 11:11:12 +02:00
c71e1b97d9 feat(l10n): advertise app localizations to iOS
The per-app language picker in iOS Settings only appears when the built app
declares multiple localizations. Add the planned languages to the Xcode
project's knownRegions (so Xcode compiles their .lproj from the String
Catalog), and have `generate` keep CFBundleLocalizations in Info.plist in sync
with supportedLanguages (currently en, fr).
2026-07-20 10:41:41 +02:00
91573f381a feat(l10n): add French translations
Translate all 253 user-facing strings to French (formal register), including
the transfer_file_count plural. Generates values-fr/strings.xml for KMP and
fr localizations in the Apple catalog (marked needs_review).
2026-07-20 10:34:52 +02:00
4448822bfa refactor(l10n): default strings to all targets
Most strings were imported with a platform-specific `targets` (usually
apple-only) just because that's where they happen to be used today. Drop the
restriction so they default to all targets and are available to KMP too; only
the four literal `%@…` format-composition keys stay apple-only.

Also fix the placeholder parser to tolerate C length modifiers (`%lld`), and
give progress_sending_to_count a proper int arg so it emits `%1$d` on both
platforms instead of a literal `%lld`.
2026-07-20 10:24:54 +02:00
3c0f29adb6 docs(l10n): describe context for every string key
Replace the placeholder `context` on all 255 keys in strings.json with a
description of where each string appears and its purpose, and regenerate the
xcstrings so the comments flow through to the Apple catalog.
2026-07-20 10:20:34 +02:00
cd6a2d66aa feat(l10n): single-source localization pipeline
Add localization/ — one strings.json is the source of truth for every
user-facing string, and a Bun CLI generates the platform files:

  loc migrate   rebuild strings.json from existing platform files (one-time)
  loc validate  structural checks (plural `other`, arg refs, coverage)
  loc generate  emit .xcstrings (Apple) + per-language strings.xml (KMP)

Canonical `{name}` placeholders are converted to each platform's positional
printf tokens using declared arg types. Plurals use CLDR categories and emit
native forms: xcstrings plural variations and Compose <plurals> blocks.

Migration folds the KMP `transfer_file_count_one`/`_other` pair into a single
plural key, so `transferFileCountResource` now returns the plural resource and
callers resolve it with pluralStringResource.
2026-07-20 10:15:28 +02:00
Hammed Abass
9a3cfb55d0 Merge pull request #20 from sudosylabs/feat/port-swift
feat(apple): native SwiftUI app for iOS, iPadOS, and macOS
2026-07-20 00:22:21 +02:00
17099d32f2 test(apple): CoreGateway seam, XCTest suite, and CI
- 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
2026-07-19 23:48:11 +02:00
e35c8c840a feat(apple): replace About source-code link with privacy policy 2026-07-19 23:20:45 +02:00
b1b8fa202c refactor(apple): adopt Swift 6 language mode
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
2026-07-19 18:39:34 +02:00
bfa489def1 feat(apple): storage screen, About content, and fixes
- 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)
2026-07-19 13:58:20 +02:00
ceccfcda71 feat(apple): transfer controls, progress fixes, and project config
- 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
2026-07-19 12:33:58 +02:00
ea376a382b feat(apple): native SwiftUI app for iOS and macOS
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.
2026-07-18 22:06:30 +02:00
601cbc486e Merge with master 2026-07-18 11:57:55 +02:00
c14a2397ae ios build 2026-07-18 11:39:35 +02:00
Hammed Abass
6973cc7351 Merge pull request #19 from sudosylabs/feat/linux-release
ci: build Linux release packages
2026-07-17 15:17:25 +02:00
ec03f210dc fix(ci): expose Android SDK to RPM container 2026-07-17 14:51:16 +02:00
d0844068bb ci: build Linux release packages 2026-07-17 02:08:57 +02:00
441 changed files with 31810 additions and 4590 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.af filter=lfs diff=lfs merge=lfs -text

230
.github/workflows/apple-release.yml vendored Normal file
View File

@@ -0,0 +1,230 @@
name: Apple release (macOS DMG)
# Builds, signs, notarizes, and publishes the direct-download macOS build:
# - a Developer IDsigned, notarized VniDrop-<version>.dmg,
# - a Sparkle appcast.xml (both attached to the GitHub Release), and
# - an updated Homebrew cask pushed to the sudosylabs/homebrew-vnidrop tap.
#
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
# Organizer / App Store Connect. This workflow only covers direct distribution.
#
# Trigger: push a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or
# run manually with an explicit version (produces artifacts, no Release).
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "0.1.0"
type: string
permissions:
contents: read
concurrency:
group: apple-release-${{ github.ref }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: Build & notarize DMG
runs-on: macos-latest
timeout-minutes: 90
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.app }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag is on master
if: github.event_name == 'push'
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
echo "Release tags must point to a commit on master" >&2
exit 1
fi
- name: Resolve version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '' }}
run: |
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
version="${GITHUB_REF_NAME#v}"
else
version="$REQUESTED_VERSION"
fi
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; }
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: stable
targets: aarch64-apple-darwin
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: apple-release-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: apple-release-cargo-
- name: Install tooling
run: brew install xcodegen swiftlint create-dmg
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Download Sparkle tools
# generate_appcast + sign_update ship in the Sparkle release tarball.
run: |
set -euo pipefail
ver="2.9.4"
curl -fsSL -o /tmp/sparkle.tar.xz \
"https://github.com/sparkle-project/Sparkle/releases/download/${ver}/Sparkle-${ver}.tar.xz"
mkdir -p /tmp/sparkle && tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle
echo "SPARKLE_BIN=/tmp/sparkle/bin" >> "$GITHUB_ENV"
- name: Import Developer ID certificate
env:
CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/signing.keychain-db"
kpw="$(openssl rand -hex 20)"
security create-keychain -p "$kpw" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$kpw" "$keychain"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kpw" "$keychain"
# Prepend our keychain so codesign/xcodebuild can find the identity.
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
rm -f "$RUNNER_TEMP/cert.p12"
- name: Store notarytool credentials
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_API_KEY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
run: |
set -euo pipefail
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
xcrun notarytool store-credentials vnidrop-notary \
--key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" \
--issuer "$NOTARY_ISSUER"
echo "NOTARY_PROFILE=vnidrop-notary" >> "$GITHUB_ENV"
- name: Write Sparkle signing key
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_ed_private_key"
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
- name: Build, sign & notarize DMG
run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}"
- name: Generate appcast
env:
RELEASE_REPO: ${{ github.repository }}
run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}"
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
path: |
apple/dist/VniDrop-*.dmg
apple/dist/appcast.xml
if-no-files-found: error
retention-days: 14
- name: Publish GitHub Release
if: github.event_name == 'push' && github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
version="${tag#v}"
if gh release view "$tag" >/dev/null 2>&1; then
echo "Release $tag already exists; refusing to replace assets" >&2
exit 1
fi
gh release create "$tag" \
"apple/dist/VniDrop-${version}.dmg" \
"apple/dist/appcast.xml" \
--verify-tag \
--title "VniDrop $version" \
--generate-notes
update-cask:
name: Update Homebrew cask
needs: build
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download DMG artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.build.outputs.version }}-macos-dmg
path: dist
- name: Render cask
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
set -euo pipefail
sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)"
sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \
-e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \
packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb
echo "Rendered cask:"; cat /tmp/vnidrop.rb
- name: Push to tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.build.outputs.version }}
run: |
set -euo pipefail
git clone "https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" tap
mkdir -p tap/Casks
cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb
cd tap
git config user.name "vnidrop-release-bot"
git config user.email "release-bot@users.noreply.github.com"
git add Casks/vnidrop.rb
git commit -m "vnidrop ${VERSION}" || { echo "no cask changes"; exit 0; }
git push

83
.github/workflows/apple.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Apple
on:
pull_request:
paths:
- "apple/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
- "Cargo.lock"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/apple.yml"
- "localization/**"
push:
branches:
- master
paths:
- "apple/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
- "Cargo.lock"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/apple.yml"
- "localization/**"
permissions:
contents: read
concurrency:
group: apple-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: macos-latest
timeout-minutes: 75
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
# Pin so the simulator device name below stays predictable.
run: sudo xcode-select -s /Applications/Xcode.app
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: apple-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: apple-cargo-
- name: Install XcodeGen
run: brew install xcodegen
- name: Install SwiftLint
# Required by the VniDrop target's SwiftLint build phase (typed-resources rules).
run: brew install swiftlint
- name: Install Bun
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
# generated from localization/strings.json at build time, not tracked.
uses: oven-sh/setup-bun@v2
- name: Build and test Apple app
run: make check-apple
- name: Build direct-download macOS target (Sparkle, unsigned)
# Keeps the VniDropDirect (.dmg/Sparkle) target compiling; signing and
# notarization happen only in apple-release.yml on a tag.
run: make build-apple-macos-direct

View File

@@ -4,12 +4,18 @@ on:
pull_request: pull_request:
paths: paths:
- "services/diagnostics-api/**" - "services/diagnostics-api/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/diagnostics-api.yml" - ".github/workflows/diagnostics-api.yml"
push: push:
branches: branches:
- master - master
paths: paths:
- "services/diagnostics-api/**" - "services/diagnostics-api/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/diagnostics-api.yml" - ".github/workflows/diagnostics-api.yml"
permissions: permissions:
@@ -23,9 +29,6 @@ jobs:
quality: quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
defaults:
run:
working-directory: services/diagnostics-api
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -37,17 +40,5 @@ jobs:
cache: npm cache: npm
cache-dependency-path: services/diagnostics-api/package-lock.json cache-dependency-path: services/diagnostics-api/package-lock.json
- name: Install dependencies - name: Check diagnostics API
run: npm ci run: make check-diagnostics
- name: Verify generated Worker types
run: npm run types:check
- name: Type-check
run: npm run typecheck
- name: Test in the Workers runtime
run: npm test
- name: Validate the deployment bundle
run: npm run deploy:dry-run

View File

@@ -4,12 +4,18 @@ on:
pull_request: pull_request:
paths: paths:
- "docs/**" - "docs/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
push: push:
branches: branches:
- master - master
paths: paths:
- "docs/**" - "docs/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/docs.yml" - ".github/workflows/docs.yml"
permissions: permissions:
@@ -23,9 +29,6 @@ jobs:
quality: quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
defaults:
run:
working-directory: docs
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -37,11 +40,5 @@ jobs:
cache: npm cache: npm
cache-dependency-path: docs/package-lock.json cache-dependency-path: docs/package-lock.json
- name: Install dependencies - name: Check documentation website
run: npm ci run: make check-docs
- name: Type-check
run: npm run typecheck
- name: Build
run: npm run build

293
.github/workflows/linux-packages.yml vendored Normal file
View File

@@ -0,0 +1,293 @@
name: Linux packages
on:
pull_request:
paths:
- ".github/workflows/linux-packages.yml"
- "packaging/linux/**"
- "assets/linux/**"
- "desktopApp/**"
- "shared/**"
- "crates/vnidrop/**"
- "Cargo.toml"
- "Cargo.lock"
- "LICENSE"
- "build.gradle.kts"
- "settings.gradle.kts"
- "gradle.properties"
- "gradle/**"
- "gradlew"
- "Makefile"
- "config.mk"
- "make/**"
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions:
contents: read
concurrency:
group: linux-packages-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
defaults:
run:
shell: bash
jobs:
build-deb:
name: Build Debian package (x64)
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install --yes fakeroot unzip
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "21.0.11+10.0.LTS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-deb-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
linux-deb-x64-cargo-1.91.0-
- name: Resolve version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Test and build Debian package
run: make package-deb VERSION=${{ steps.version.outputs.app }}
- name: Upload Debian artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-linux-deb-x64
path: build/release/linux/deb/
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Summarize Debian package
run: |
echo "### Debian package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
echo "- Architecture: amd64" >> "$GITHUB_STEP_SUMMARY"
echo "- Build baseline: Ubuntu 22.04" >> "$GITHUB_STEP_SUMMARY"
build-rpm:
name: Build RPM package (x64)
runs-on: ubuntu-24.04
container:
image: registry.fedoraproject.org/fedora:43
volumes:
- /usr/local/lib/android/sdk:/usr/local/lib/android/sdk
timeout-minutes: 90
env:
ANDROID_HOME: /usr/local/lib/android/sdk
ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
CARGO_TERM_COLOR: always
steps:
- name: Install build and packaging tools
run: |
dnf install --assumeyes \
alsa-lib \
cpio \
curl \
cups-libs \
desktop-file-utils \
findutils \
fontconfig \
freetype \
gcc \
gcc-c++ \
git \
gzip \
gtk3 \
libX11 \
libXext \
libXi \
libXrandr \
libXrender \
libXtst \
make \
mesa-libGL \
rpm-build \
tar \
unzip \
which \
xz \
zstd
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "21.0.11+10.0.LTS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-rpm-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
linux-rpm-x64-cargo-1.91.0-
- name: Resolve version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Build RPM package
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
- name: Upload RPM artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-linux-rpm-x64
path: build/release/linux/rpm/
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Summarize RPM package
run: |
echo "### RPM package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY"
publish-release:
name: Publish GitHub Release assets
if: github.event_name == 'push' && github.ref_type == 'tag'
needs:
- build-deb
- build-rpm
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Checkout release history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag is on master
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
echo "Release tags must point to a commit on master" >&2
exit 1
fi
- name: Download Linux artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: vnidrop-*-linux-*-x64
path: build/release/linux
merge-multiple: true
- name: Verify artifacts and checksums
run: |
cd build/release/linux
shopt -s nullglob
deb_packages=(*.deb)
rpm_packages=(*.rpm)
checksum_files=(*.sha256)
if (( ${#deb_packages[@]} != 1 || ${#rpm_packages[@]} != 1 || ${#checksum_files[@]} != 2 )); then
echo "Expected one DEB, one RPM, and two checksum sidecars" >&2
exit 1
fi
version=${GITHUB_REF_NAME#v}
if [[ ${deb_packages[0]} != "vnidrop_${version}-1_amd64.deb" || ${rpm_packages[0]} != "vnidrop-${version}-1.x86_64.rpm" ]]; then
echo "Downloaded package names do not match tag $GITHUB_REF_NAME" >&2
exit 1
fi
sha256sum --check "${checksum_files[@]}"
sha256sum "${deb_packages[@]}" "${rpm_packages[@]}" > SHA256SUMS
rm -- "${checksum_files[@]}"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag=${GITHUB_REF_NAME}
version=${tag#v}
if gh release view "$tag" >/dev/null 2>&1; then
echo "GitHub Release $tag already exists; refusing to replace its assets" >&2
exit 1
fi
gh release create "$tag" \
build/release/linux/*.deb \
build/release/linux/*.rpm \
build/release/linux/SHA256SUMS \
--verify-tag \
--title "VniDrop $version" \
--generate-notes

View File

@@ -6,6 +6,9 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/rust-core.yml" - ".github/workflows/rust-core.yml"
push: push:
# Only after merge (or direct master pushes). Feature-branch work is covered # Only after merge (or direct master pushes). Feature-branch work is covered
@@ -16,6 +19,9 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/rust-core.yml" - ".github/workflows/rust-core.yml"
permissions: permissions:
@@ -33,18 +39,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust quality components - name: Install Rust quality components
run: rustup component add clippy rustfmt run: rustup component add clippy rustfmt
- name: Check formatting - name: Check Rust core
run: cargo fmt --all -- --check run: make check-rust
- name: Run strict Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run unit and integration tests
run: cargo test --workspace --all-targets
- name: Check documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install cargo-audit --locked run: cargo install cargo-audit --locked
- name: Audit Rust dependencies - name: Audit Rust dependencies
# Ignores are listed in .cargo/audit.toml for known transitive issues. # Ignores are listed in .cargo/audit.toml for known transitive issues.
run: cargo audit run: make audit-rust

View File

@@ -15,6 +15,9 @@ on:
- "gradle.properties" - "gradle.properties"
- "androidApp/**" - "androidApp/**"
- "desktopApp/**" - "desktopApp/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/shared-kmp.yml" - ".github/workflows/shared-kmp.yml"
push: push:
branches: branches:
@@ -32,6 +35,9 @@ on:
- "gradle.properties" - "gradle.properties"
- "androidApp/**" - "androidApp/**"
- "desktopApp/**" - "desktopApp/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/shared-kmp.yml" - ".github/workflows/shared-kmp.yml"
permissions: permissions:
@@ -44,8 +50,7 @@ concurrency:
jobs: jobs:
jvm-test: jvm-test:
# Host Rust embedding is enabled only for the current Gobley host target. # Host Rust embedding is enabled only for the current Gobley host target.
# This job stays on macOS to cover the Apple targets as well as JVM tests. runs-on: ubuntu-latest
runs-on: macos-latest
timeout-minutes: 75 timeout-minutes: 75
steps: steps:
- name: Checkout - name: Checkout
@@ -63,7 +68,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android targets: aarch64-linux-android,x86_64-linux-android
- name: Cache Cargo - name: Cache Cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -95,7 +100,7 @@ jobs:
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV" echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
- name: Run shared JVM tests - name: Run shared JVM tests
run: ./gradlew :shared:jvmTest --no-daemon --stacktrace run: make check-shared
- name: Verify Android native libraries - name: Verify Android native libraries
run: ./gradlew :androidApp:verifyDebugVnidropLibraries --no-daemon --stacktrace run: make verify-android-libs

2
.gitignore vendored
View File

@@ -19,7 +19,9 @@ captures
node_modules/ node_modules/
target/ target/
.junie .junie
config.override.mk
# Local design export scratch # Local design export scratch
output/ output/
.screenshots .screenshots
apple/RELEASE-MACOS.md

View File

@@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees:
## Project overview ## Project overview
VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop). VniDrop is a cross-platform **local P2P file transfer** app.
| Layer | Path | Responsibility | | Layer | Path | Responsibility |
|-------|------|----------------| |-------|------|----------------|
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming | | Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges | | Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux |
| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells | | Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells |
| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings |
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**. **Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
Do not design features that move transfer payloads through Kotlin heap by default. Do not design features that move transfer payloads through Kotlin heap by default.
@@ -47,72 +48,76 @@ Domain docs (reference, do not paste into PRs):
8. **Every bug fix includes a regression test** at the lowest layer that catches it. 8. **Every bug fix includes a regression test** at the lowest layer that catches it.
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test) 9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
and fix failures before finishing. and fix failures before finishing.
10. **`localization/strings.json` is the single source of truth for all localized
strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/
values*/strings.xml`) and the Apple catalog + accessors
(`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/
L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run
src/cli.ts generate`) — never hand-edit them. To add/change a string: edit
`strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then
regenerate. A key referenced in code but only present in a generated file will
be silently dropped the next time generation runs.
--- ---
## Build and test ## Build and test
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17, Install prerequisites when missing: GNU Make + Bash, Rust stable + rustfmt + clippy, JDK 17,
Android NDK/SDK only if building Android, Xcode only for iOS. Android NDK/SDK only if building Android, Xcode only for the native Apple app.
### Rust core (`crates/vnidrop` or workspace root) ### Rust core (`crates/vnidrop` or workspace root)
Run from the **repo root** (Cargo workspace): Run from the **repo root** (Cargo workspace):
```bash ```bash
cargo fmt --all -- --check make check-rust
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets
``` ```
Focused: Focused:
```bash ```bash
cargo test -p vnidrop make test-rust
cargo test -p vnidrop --test output_sink make test-rust-output-sink
cargo test -p vnidrop --test transfer make test-rust-transfer
cargo test -p vnidrop --test approval make test-rust-approval
cargo test -p vnidrop --test lifecycle make test-rust-lifecycle
``` ```
After finishing Rust edits, format: After finishing Rust edits, format:
```bash ```bash
cargo fmt --all make format
``` ```
CI also runs `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings` `make check-rust` includes documentation with warnings denied, matching
(see `.github/workflows/rust-core.yml`). Run it before large Rust public-API changes. `.github/workflows/rust-core.yml`.
### Shared KMP / Compose (`shared/`) ### Shared KMP / Compose (`shared/`)
```bash ```bash
./gradlew :shared:jvmTest make check-shared
./gradlew :shared:compileKotlinJvm
``` ```
Other targets (slower / machine-dependent): Other targets (slower / machine-dependent):
```bash ```bash
./gradlew :shared:testAndroidHostTest make test-android-host
./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode make check-android
./gradlew :androidApp:assembleDebug make run-desktop
./gradlew :desktopApp:run
``` ```
**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled **Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the
for the current host and architecture, so local Linux and Windows builds embed current host and architecture, so local desktop builds embed their matching
their matching desktop Rust library. Prefer macOS only when exact CI parity is Rust library.
required.
### What to run before finishing ### What to run before finishing
| You changed… | Minimum verification | | You changed… | Minimum verification |
|--------------|----------------------| |--------------|----------------------|
| `crates/vnidrop/**` only | `cargo fmt`, `cargo clippy … -D warnings`, `cargo test -p vnidrop` | | `crates/vnidrop/**` only | `make check-rust` |
| Cancel / export / sinks | Above + `cargo test -p vnidrop --test output_sink` | | Cancel / export / sinks | Above + `make test-rust-output-sink` |
| `shared/**` only | `./gradlew :shared:jvmTest` | | `shared/**` only | `make test-shared` |
| Both | Rust suite + `:shared:jvmTest` | | Both | `make test-rust test-shared` |
| Docs only | No suite required; verify links/paths | | Docs only | No suite required; verify links/paths |
Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
@@ -144,12 +149,12 @@ shared/src/commonMain/kotlin/com/vnidrop/app/
core/ # CoreGateway, models, pickers interfaces core/ # CoreGateway, models, pickers interfaces
feature/send|receive|approvals|settings|app/ feature/send|receive|approvals|settings|app/
ui/theme|components|navigation|feedback|state/ ui/theme|components|navigation|feedback|state/
androidMain|iosMain|jvmMain/ # expect/actual implementations androidMain|jvmMain/ # expect/actual implementations
``` ```
### Platform file rules (do not violate) ### Platform file rules (do not violate)
- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`. - Windows/Linux desktop: paths; directory walk in Rust when `is_directory`.
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD. - Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
Folder share expands SAF trees in Kotlin to per-file FDs + relative names. Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write. - Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
@@ -295,6 +300,8 @@ branch from updated `master`.
- Flaky multi-minute sleeps in tests - Flaky multi-minute sleeps in tests
- Unsigned commits when signing is required - Unsigned commits when signing is required
- Force-push or secret commits without explicit user direction - Force-push or secret commits without explicit user direction
- Hand-editing generated localization files (`values*/strings.xml`,
`Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json`
--- ---

View File

@@ -35,24 +35,44 @@ Use a branch name that describes the outcome, such as
Install the tools needed for the area you plan to change: Install the tools needed for the area you plan to change:
- GNU Make and Bash for the root command interface
- JDK 17 or newer for Gradle and application builds - JDK 17 or newer for Gradle and application builds
- Rust stable with `rustfmt` and Clippy for the transfer core - Rust stable with `rustfmt` and Clippy for the transfer core
- Android SDK and NDK for Android builds - Android SDK and NDK for Android builds
- Xcode on macOS for iOS builds and simulator tests - Xcode and XcodeGen on macOS for native Apple builds and simulator tests
- Node.js 22.12 or newer for the optional diagnostics service - Node.js 22.12 or newer for the optional diagnostics service
The first Rust and Gradle builds may take several minutes while dependencies are The first Rust and Gradle builds may take several minutes while dependencies are
downloaded and native components are compiled. downloaded and native components are compiled.
## Command Interface
Run development commands through the root `Makefile`. It keeps local and CI
commands aligned while continuing to delegate builds to Cargo, Gradle, Xcode,
Bun, and npm:
```bash
make help # list commands
make doctor # report missing host tools
make setup # install repository-local JavaScript dependencies
make check # portable Rust, shared, localization, docs, and service checks
```
Configuration can be passed on the command line, for example
`make package-deb VERSION=1.2.0`, or placed in an ignored
`config.override.mk`. Windows use requires GNU Make in a Bash environment; the
underlying Gradle and PowerShell entry points remain available when Make is not
installed.
## Repository Structure ## Repository Structure
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming | | `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
| `shared/` | Shared Kotlin Multiplatform UI and platform bridges | | `shared/` | Compose Multiplatform UI and bridges for Android, Windows, and Linux |
| `androidApp/` | Android application shell | | `androidApp/` | Android application shell |
| `iosApp/` | iOS application shell | | `desktopApp/` | Windows/Linux JVM application shell |
| `desktopApp/` | Desktop JVM application shell | | `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms |
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service | | `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
Read the nearest contributor guidance before editing: Read the nearest contributor guidance before editing:
@@ -87,43 +107,48 @@ Run checks from the repository root. Choose the suite for the files you changed.
### Rust Core ### Rust Core
```bash ```bash
cargo fmt --all make format
cargo clippy --workspace --all-targets -- -D warnings make test-rust
cargo test -p vnidrop
``` ```
For cancel, export, or output-sink changes, also run: For cancel, export, or output-sink changes, also run:
```bash ```bash
cargo test -p vnidrop --test output_sink make test-rust-output-sink
``` ```
For broader core changes, run the complete workspace suite: For broader core changes, run the complete workspace suite:
```bash ```bash
cargo test --workspace --all-targets make check-rust
``` ```
### Shared Kotlin and Compose ### Shared Kotlin and Compose
```bash ```bash
./gradlew :shared:jvmTest make test-shared
``` ```
Platform-specific checks may also be appropriate: Platform-specific checks may also be appropriate:
```bash ```bash
./gradlew :shared:testAndroidHostTest make test-android-host
./gradlew :shared:iosSimulatorArm64Test make check-android
./gradlew :androidApp:assembleDebug
``` ```
### Native Apple App
```bash
make check-apple
```
Override the selected simulator when needed with
`make check-apple APPLE_DESTINATION='platform=iOS Simulator,name=iPhone 16'`.
### Diagnostics Service ### Diagnostics Service
```bash ```bash
cd services/diagnostics-api make check-diagnostics
npm ci
npm run check
``` ```
If a required check cannot run on your machine, explain why in the pull request If a required check cannot run on your machine, explain why in the pull request

443
Cargo.lock generated
View File

@@ -61,6 +61,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.103" version = "1.0.103"
@@ -457,9 +507,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
@@ -496,6 +546,46 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "clap"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.4" version = "0.5.4"
@@ -511,6 +601,12 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -732,38 +828,17 @@ dependencies = [
] ]
[[package]] [[package]]
name = "darling" name = "dashmap"
version = "0.20.11" version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [ dependencies = [
"darling_core", "cfg-if",
"darling_macro", "crossbeam-utils",
] "hashbrown 0.14.5",
"lock_api",
[[package]] "once_cell",
name = "darling_core" "parking_lot_core",
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.118",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.118",
] ]
[[package]] [[package]]
@@ -789,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [ dependencies = [
"data-encoding", "data-encoding",
"syn 2.0.118", "syn 1.0.109",
] ]
[[package]] [[package]]
@@ -834,37 +909,6 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.118",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -913,6 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer 0.12.1", "block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2", "crypto-common 0.2.2",
] ]
@@ -1429,6 +1474,12 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1816,12 +1867,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "identity-hash" name = "identity-hash"
version = "0.1.0" version = "0.1.0"
@@ -1921,9 +1966,9 @@ dependencies = [
[[package]] [[package]]
name = "iroh" name = "iroh"
version = "1.0.1" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2e38557969901f8b356d1ebd882253bab98cc81500d53e7bcbf33f56082303" checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72"
dependencies = [ dependencies = [
"backon", "backon",
"blake3", "blake3",
@@ -1972,9 +2017,9 @@ dependencies = [
[[package]] [[package]]
name = "iroh-base" name = "iroh-base"
version = "1.0.1" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61cdf012298adc13f5c2c821ad87214fecc9ac54c751301d45bf62b229a85da1" checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"data-encoding", "data-encoding",
@@ -2032,9 +2077,9 @@ dependencies = [
[[package]] [[package]]
name = "iroh-dns" name = "iroh-dns"
version = "1.0.1" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c24b83aae5ed4eced1c3724204c083c28c84c5d225a88e277eceeccce3dc3bd" checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"cfg_aliases", "cfg_aliases",
@@ -2073,12 +2118,20 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
dependencies = [ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"iroh-metrics-derive", "iroh-metrics-derive",
"itoa", "itoa",
"n0-error", "n0-error",
"portable-atomic", "portable-atomic",
"reqwest",
"rustls",
"rustls-platform-verifier",
"ryu", "ryu",
"serde", "serde",
"tokio",
"tokio-util",
"tracing", "tracing",
] ]
@@ -2096,13 +2149,15 @@ dependencies = [
[[package]] [[package]]
name = "iroh-relay" name = "iroh-relay"
version = "1.0.1" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f16a5505939f9250297ff2f1210b5142d13618f496eab03ce3f964fc47e2e2b" checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193"
dependencies = [ dependencies = [
"blake3", "blake3",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
"clap",
"dashmap",
"data-encoding", "data-encoding",
"derive_more", "derive_more",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -2123,19 +2178,29 @@ dependencies = [
"pin-project", "pin-project",
"postcard", "postcard",
"rand 0.10.2", "rand 0.10.2",
"rcgen",
"reloadable-state",
"reqwest", "reqwest",
"rustls", "rustls",
"rustls-cert-file-reader",
"rustls-cert-reloadable-resolver",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_bytes", "serde_bytes",
"serde_json",
"sha1 0.11.0",
"simdutf8",
"strum", "strum",
"time",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-rustls-acme",
"tokio-util", "tokio-util",
"tokio-websockets", "tokio-websockets",
"toml 1.1.2+spec-1.1.0",
"tracing", "tracing",
"tracing-subscriber",
"url", "url",
"vergen-gitcl",
"webpki-roots", "webpki-roots",
"ws_stream_wasm", "ws_stream_wasm",
] ]
@@ -2219,6 +2284,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2669,9 +2740,9 @@ dependencies = [
[[package]] [[package]]
name = "noq" name = "noq"
version = "1.0.1" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" checksum = "e11803df44ac03a30988d61585ea50885d5428e42da944fe1e498799da7886a2"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
@@ -2691,9 +2762,9 @@ dependencies = [
[[package]] [[package]]
name = "noq-proto" name = "noq-proto"
version = "1.0.1" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" checksum = "334c3c9833f7b2c573cceb9896ddc7aaeb58c8807cbb63211b24d1fe88bf866e"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2720,9 +2791,9 @@ dependencies = [
[[package]] [[package]]
name = "noq-udp" name = "noq-udp"
version = "1.0.1" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" checksum = "bde7a5d5102f1cff03d482240f0ed20551661f63663620f4b26112ed751165e9"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -2834,15 +2905,6 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.4" version = "0.6.4"
@@ -2952,6 +3014,12 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.1" version = "0.3.1"
@@ -3494,6 +3562,23 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reloadable-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0"
[[package]]
name = "reloadable-state"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8"
dependencies = [
"arc-swap",
"reloadable-core",
"tokio",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -3517,6 +3602,8 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier", "rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@@ -3623,6 +3710,40 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-cert-file-reader"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff"
dependencies = [
"rustls-cert-read",
"rustls-pki-types",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "rustls-cert-read"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-cert-reloadable-resolver"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8"
dependencies = [
"futures-util",
"reloadable-state",
"rustls",
"rustls-cert-read",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "rustls-native-certs" name = "rustls-native-certs"
version = "0.8.4" version = "0.8.4"
@@ -3853,6 +3974,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -3886,6 +4016,17 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
] ]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]] [[package]]
name = "sha1_smol" name = "sha1_smol"
version = "1.0.1" version = "1.0.1"
@@ -4189,7 +4330,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"rand 0.8.6", "rand 0.8.6",
"rsa", "rsa",
"sha1", "sha1 0.10.6",
"sha2 0.10.9", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
@@ -4409,7 +4550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.3", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -4481,9 +4622,7 @@ checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [ dependencies = [
"deranged", "deranged",
"js-sys", "js-sys",
"libc",
"num-conv", "num-conv",
"num_threads",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
@@ -4569,6 +4708,34 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-rustls-acme"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f"
dependencies = [
"async-trait",
"base64",
"chrono",
"futures",
"log",
"num-bigint",
"pem",
"proc-macro2",
"rcgen",
"reqwest",
"ring",
"rustls",
"serde",
"serde_json",
"thiserror 2.0.18",
"time",
"tokio",
"tokio-rustls",
"webpki-roots",
"x509-parser",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.18" version = "0.1.18"
@@ -4627,6 +4794,21 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -4657,6 +4839,12 @@ dependencies = [
"winnow 1.0.3", "winnow 1.0.3",
] ]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -4835,13 +5023,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4" checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"camino",
"cargo_metadata", "cargo_metadata",
"clap",
"uniffi_bindgen", "uniffi_bindgen",
"uniffi_core", "uniffi_core",
"uniffi_macros", "uniffi_macros",
"uniffi_pipeline", "uniffi_pipeline",
] ]
[[package]]
name = "uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]] [[package]]
name = "uniffi_bindgen" name = "uniffi_bindgen"
version = "0.29.4" version = "0.29.4"
@@ -4861,7 +5058,7 @@ dependencies = [
"serde", "serde",
"tempfile", "tempfile",
"textwrap", "textwrap",
"toml", "toml 0.5.11",
"uniffi_internal_macros", "uniffi_internal_macros",
"uniffi_meta", "uniffi_meta",
"uniffi_pipeline", "uniffi_pipeline",
@@ -4907,7 +5104,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"syn 2.0.118", "syn 2.0.118",
"toml", "toml 0.5.11",
"uniffi_meta", "uniffi_meta",
] ]
@@ -4983,6 +5180,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.23.4"
@@ -5007,43 +5210,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vergen"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
dependencies = [
"anyhow",
"derive_builder",
"rustversion",
"vergen-lib",
]
[[package]]
name = "vergen-gitcl"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
dependencies = [
"anyhow",
"derive_builder",
"rustversion",
"time",
"vergen",
"vergen-lib",
]
[[package]]
name = "vergen-lib"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
dependencies = [
"anyhow",
"derive_builder",
"rustversion",
]
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -5063,6 +5229,7 @@ dependencies = [
"futures-lite", "futures-lite",
"iroh", "iroh",
"iroh-blobs", "iroh-blobs",
"iroh-relay",
"irpc", "irpc",
"irpc-iroh", "irpc-iroh",
"libc", "libc",
@@ -5275,7 +5442,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/vnidrop"] members = ["crates/vnidrop", "crates/uniffi-bindgen"]
resolver = "2" resolver = "2"
[profile.dev] [profile.dev]

View File

@@ -187,7 +187,8 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [yyyy] [name of copyright owner] Copyright 2026 VniDrop
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

187
Makefile Normal file
View File

@@ -0,0 +1,187 @@
ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
include $(ROOT)/config.mk
-include $(ROOT)/config.override.mk
include $(ROOT)/make/release.mk
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
.PHONY: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
.PHONY: check-localization localization localization-migrate
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
help: ## Show available commands and common configuration variables.
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
@printf '\nCommon variables:\n'
@printf ' %-28s %s\n' 'VERSION=x.y.z' 'Package version (default: $(VERSION))'
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
@printf ' %-28s %s\n' 'APPLE_CODE_SIGNING=NO|YES' 'Enable Apple code signing (default: $(APPLE_CODE_SIGNING))'
doctor: ## Check that tools required by the current host are available.
@missing=0; \
for tool in "$(firstword $(CARGO))" java "$(firstword $(NPM))" "$(firstword $(BUN))"; do \
if command -v "$$tool" >/dev/null 2>&1; then \
printf 'ok %s\n' "$$tool"; \
else \
printf 'missing %s\n' "$$tool"; \
missing=1; \
fi; \
done; \
if [[ ! -f "$(GRADLE)" ]]; then printf 'missing %s\n' "$(GRADLE)"; missing=1; else printf 'ok %s\n' "$(GRADLE)"; fi; \
if [[ "$(HOST_OS)" == macos ]]; then \
for tool in "$(firstword $(XCODEBUILD))" "$(firstword $(XCODEGEN))"; do \
if command -v "$$tool" >/dev/null 2>&1; then printf 'ok %s\n' "$$tool"; else printf 'missing %s\n' "$$tool"; missing=1; fi; \
done; \
fi; \
exit $$missing
setup: setup-localization setup-docs setup-diagnostics ## Install repository-local JavaScript dependencies.
setup-localization: ## Install localization CLI dependencies with Bun.
cd $(ROOT)/localization && $(BUN) install --frozen-lockfile
setup-docs: ## Install documentation website dependencies.
cd $(ROOT)/docs && $(NPM) ci
setup-diagnostics: ## Install diagnostics Worker dependencies.
cd $(ROOT)/services/diagnostics-api && $(NPM) ci
format: ## Format Rust sources.
cd $(ROOT) && $(CARGO) fmt --all
test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
check: check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check
cd $(ROOT) && $(CARGO) clippy --workspace --all-targets -- -D warnings
cd $(ROOT) && $(CARGO) test --workspace --all-targets
cd $(ROOT) && RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps
audit-rust: ## Audit Rust dependencies (requires cargo-audit).
cd $(ROOT) && $(CARGO) audit
test-rust: ## Run the focused Rust core suite.
cd $(ROOT) && $(CARGO) test -p vnidrop
test-rust-all: ## Run every Rust workspace test target.
cd $(ROOT) && $(CARGO) test --workspace --all-targets
test-rust-transfer: ## Run Rust transfer integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test transfer
test-rust-approval: ## Run Rust approval integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test approval
test-rust-lifecycle: ## Run Rust lifecycle integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test lifecycle
test-rust-output-sink: ## Run Rust output-sink integration tests.
cd $(ROOT) && $(CARGO) test -p vnidrop --test output_sink
check-shared: ## Test and compile the shared Android/JVM module.
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)
test-shared: ## Run shared JVM tests.
cd $(ROOT) && $(GRADLE) :shared:jvmTest $(GRADLE_FLAGS)
test-android-host: ## Run Android host-side shared tests.
cd $(ROOT) && $(GRADLE) :shared:testAndroidHostTest $(GRADLE_FLAGS)
check-android: ## Build Android debug and verify packaged Rust libraries.
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
verify-android-libs: ## Verify the Rust libraries packaged in the Android debug app.
cd $(ROOT) && $(GRADLE) :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
build-android: ## Build the Android debug APK.
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug $(GRADLE_FLAGS)
run-desktop: ## Run the Windows/Linux Compose desktop app.
cd $(ROOT) && $(GRADLE) :desktopApp:run $(GRADLE_FLAGS)
apple-core: ## Build the Rust XCFramework and generated Swift bindings.
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
apple-project: apple-core localization ## Generate the native Apple Xcode project.
cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
cd $(ROOT)/apple && $(OPEN) VniDrop.xcodeproj
build-apple-macos: apple-project ## Build the native macOS app (unsigned by default).
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh $(VERSION)
open-apple: build-apple-macos ## Build and launch the native macOS app.
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
build-apple-ios: apple-project ## Build the native iOS simulator app (unsigned by default).
@destination="$(APPLE_DESTINATION)"; \
if [[ -z "$$destination" ]]; then \
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
destination="platform=iOS Simulator,id=$$device_id"; \
fi; \
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
check-apple: apple-project ## Build the Apple core and run iOS simulator tests.
@destination="$(APPLE_DESTINATION)"; \
if [[ -z "$$destination" ]]; then \
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
destination="platform=iOS Simulator,id=$$device_id"; \
fi; \
printf 'Testing on: %s\n' "$$destination"; \
cd $(ROOT)/apple && $(XCODEBUILD) test -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING)
check-localization: setup-localization ## Validate the localization source catalog.
cd $(ROOT)/localization && $(BUN) run validate
localization: setup-localization ## Regenerate Apple and KMP localization resources.
cd $(ROOT)/localization && $(BUN) run generate
localization-migrate: setup-localization ## Rebuild strings.json from platform resources.
cd $(ROOT)/localization && $(BUN) run migrate
check-docs: setup-docs ## Lint, type-check, and build the documentation website.
cd $(ROOT)/docs && $(NPM) run lint
cd $(ROOT)/docs && $(NPM) run typecheck
cd $(ROOT)/docs && $(NPM) run build
run-docs: setup-docs ## Run the documentation development server.
cd $(ROOT)/docs && $(NPM) run dev
check-diagnostics: setup-diagnostics ## Run diagnostics types, tests, and deployment dry-run.
cd $(ROOT)/services/diagnostics-api && $(NPM) run check
run-diagnostics: setup-diagnostics ## Run the diagnostics Worker locally.
cd $(ROOT)/services/diagnostics-api && $(NPM) run dev
diagnostics-db-local: setup-diagnostics ## Apply diagnostics database migrations locally.
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:local
diagnostics-db-remote: setup-diagnostics ## Apply diagnostics database migrations to the configured remote D1 database.
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:remote
diagnostics-typegen: setup-diagnostics ## Regenerate diagnostics Worker binding types.
cd $(ROOT)/services/diagnostics-api && $(NPM) run typegen
deploy-diagnostics: setup-diagnostics ## Check and deploy the diagnostics Worker to Cloudflare.
cd $(ROOT)/services/diagnostics-api && $(NPM) run deploy

View File

@@ -50,6 +50,42 @@ mobile networks. If a direct path cannot be established, it can forward the
same end-to-end encrypted connection through a relay. The relay forwards same end-to-end encrypted connection through a relay. The relay forwards
encrypted packets; it is not a VniDrop file store. encrypted packets; it is not a VniDrop file store.
### Custom relay servers
VniDrop uses Iroh's public relay and discovery infrastructure by default. In
**Settings → Network**, users can select one of four policies:
- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN
connections whenever possible.
- **Strict custom:** use only up to eight configured custom HTTPS relays or
direct connections. Startup reports an error if none of the custom relays can
be established.
- **Custom with direct fallback:** prefer the configured custom relays, but
continue with direct connections if they are unavailable.
- **Local only:** disable every relay and allow direct connections only,
primarily for devices on the same network.
Strict custom, custom with direct fallback, and local only never use public
relays or public discovery, including relay addresses advertised by incoming
invitations.
Applying a relay change restarts VniDrop's network engine, so active transfers
and shares must be stopped first. The app tests the new configuration and
restores the previous one if it cannot connect. Invitations created for an old
relay configuration may need to be shared again; stopped shares never expose
their stale invitations. If a long relay profile makes an invitation too large
for a QR code, use the native share action or export the invitation file.
Relay credentials embedded in URLs are deliberately rejected and bearer-token
authentication is not currently supported. A self-hosted relay must either
accept the connecting endpoints or authorize their endpoint IDs independently;
the current device ID is shown in **Settings → Network** for this purpose.
Configure the same relay profile on participating devices. A custom relay needs
a TLS certificate issued by a publicly trusted WebPKI certificate authority;
private or enterprise CAs installed only in the operating system are not used
in this version. For resilient deployments, configure at least two relays in
different failure domains.
## Why Iroh and `iroh-blobs`? ## Why Iroh and `iroh-blobs`?
VniDrop combines a networking layer with its own sharing rules: VniDrop combines a networking layer with its own sharing rules:
@@ -88,7 +124,9 @@ people, especially when using **Anyone with this transfer**.
- Per-receiver requests, approvals, progress, and delivery status - Per-receiver requests, approvals, progress, and delivery status
- Cancel, stop sharing, and local transfer history - Cancel, stop sharing, and local transfer history
- Safe receive destinations that do not silently overwrite existing files - Safe receive destinations that do not silently overwrite existing files
- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI - Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
Windows, and Linux
- Strict custom HTTPS relay profiles with safe apply and rollback
- Opt-in diagnostics with transfer contents, invitations, and file paths - Opt-in diagnostics with transfer contents, invitations, and file paths
excluded excluded
@@ -117,14 +155,21 @@ if you want to try the current version.
git clone https://github.com/vnidrop/vnidrop.git git clone https://github.com/vnidrop/vnidrop.git
cd vnidrop cd vnidrop
# Desktop # List the supported development commands and check prerequisites
./gradlew :desktopApp:run make help
make doctor
# Windows/Linux desktop
make run-desktop
# Android debug build # Android debug build
./gradlew :androidApp:assembleDebug make build-android
# iOS # Build and launch the macOS app
open iosApp/iosApp.xcodeproj make open-apple
# Open the native project for iOS, iPadOS, or Xcode development
make open-apple-project
``` ```
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup, See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,

24
apple/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Generated by apple/scripts/build-core.sh
.build-core/
VnidropCore/vnidrop.xcframework/
VnidropCore/Sources/VnidropCore/Vnidrop.swift
# Generated from localization/strings.json (cd localization && bun run src/cli.ts generate)
VniDrop/Resources/Localizable.xcstrings
VniDrop/Generated/
# Generated by XcodeGen from project.yml
VniDrop.xcodeproj/
# Per-developer signing (team id); Signing.xcconfig optionally includes it
Local.xcconfig
# SwiftPM / Xcode
.build/
.swiftpm/
DerivedData/
*.xcuserstate
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
.build-dmg/
dist/

49
apple/.swiftlint.yml Normal file
View File

@@ -0,0 +1,49 @@
# Focused lint for the native app: enforce the typed-resources convention only
# (no default style rules, so this stays signal, not noise).
only_rules:
- custom_rules
included:
- VniDrop
excluded:
- VniDrop/Generated
custom_rules:
raw_localized_string:
name: "Raw localized key"
regex: 'String\(localized:\s*"'
message: "Use a typed L10n.* accessor, not a raw key string."
severity: warning
raw_localized_string_key:
name: "Raw LocalizedStringKey"
regex: 'LocalizedStringKey\("'
message: "Use a typed L10n.* accessor instead of a raw key."
severity: warning
raw_sf_symbol:
name: "Raw SF Symbol"
regex: 'system(Name|Image):\s*"'
message: "Use SFSafeSymbols: Image(systemSymbol:) or systemSymbol:."
severity: warning
raw_swiftui_string_literal:
name: "Raw SwiftUI string"
# A non-empty string literal as the leading arg of a view initializer is an
# implicit LocalizedStringKey. Empty labels (e.g. Picker("", …)) are allowed.
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
severity: warning
raw_alert_message:
name: "Raw NFC/alert message"
# User-facing UIKit/CoreNFC prompts (e.g. NFCReaderSession.alertMessage) must
# be localized, not hardcoded English.
regex: '\balertMessage\s*=\s*"'
message: "Assign a localized value (String(localized: L10n.*)), not a raw string literal."
severity: warning
raw_invitation_error:
name: "Raw InvitationError literal"
# InvitationError.raw is the escape hatch for genuinely dynamic system/core
# messages; a string literal here is a loose user-facing string that belongs
# in a typed InvitationError case mapped to L10n in UserFacingError.swift.
regex: 'InvitationError\.raw\("'
message: "Add a typed InvitationError case + L10n mapping instead of a literal .raw(\"…\")."
severity: warning

43
apple/Package.swift Normal file
View File

@@ -0,0 +1,43 @@
// swift-tools-version:5.9
import PackageDescription
// Core/UI Swift sources built as a library so the shared logic can be typechecked
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
// Xcode project links the same sources plus the app entry point.
let package = Package(
name: "VniDropApp",
defaultLocalization: "en",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "VniDropApp", targets: ["VniDropApp"]),
],
dependencies: [
.package(path: "VnidropCore"),
],
targets: [
.target(
name: "VniDropApp",
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
path: "VniDrop",
// The @main entry belongs to the Xcode app target only; excluding it
// keeps this library free of a conflicting `_main` symbol for tests.
exclude: ["Resources", "App/VniDropApp.swift"],
// The Rust core (iroh network stack) links these system libraries. The
// Xcode app target must add the same frameworks under "Link Binary With
// Libraries" (SystemConfiguration, Security, libresolv).
linkerSettings: [
.linkedFramework("SystemConfiguration"),
.linkedFramework("Security"),
.linkedLibrary("resolv"),
]
),
.testTarget(
name: "VniDropAppTests",
dependencies: ["VniDropApp"],
path: "Tests"
),
]
)

114
apple/README.md Normal file
View File

@@ -0,0 +1,114 @@
# 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 (ported progress-derivation assertions)
Package.swift # builds VniDrop/ as a library for CLI build/test
project.yml # XcodeGen spec for the iOS/macOS app target
```
## 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`).
```bash
# 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
```
### 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.
```bash
make build-apple-macos-direct # unsigned compile-check of the direct target
make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg
```
Full signing, notarization, appcast, and cask flow: see
[`RELEASE-MACOS.md`](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.
## Command-line typecheck & tests
`Package.swift` builds the same sources as a library (minus the `@main` entry),
so the shared logic can be checked and unit-tested without Xcode:
```bash
cd apple
swift build # macOS
swift test # runs Tests/ (ported progress-derivation assertions)
# iOS typecheck:
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
```
## 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 both `Package.swift` (for CLI build/test) and
`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.
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
hides the diagnostics toggle when not compiled in.
```

6
apple/Signing.xcconfig Normal file
View File

@@ -0,0 +1,6 @@
// VniDrop development and CI builds are intentionally unsigned.
CODE_SIGNING_ALLOWED = NO
CODE_SIGNING_REQUIRED = NO
// Signed local builds can opt in through this ignored file.
#include? "Local.xcconfig"

View File

@@ -0,0 +1,53 @@
import XCTest
@testable import VniDrop
/// Ports app-level assertions: core initialization on launch, destination
/// selection guard, and theme following preferences.
@MainActor
final class AppModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> AppModel {
AppModel(
environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()),
repository: core,
preferences: preferences,
messages: UiMessageController()
)
}
func testInitializesCoreOnLaunch() async {
let core = FakeCoreGateway()
_ = makeModel(core, preferences: Fixtures.preferences())
await waitUntil { core.state.isInitialized }
XCTAssertTrue(core.state.isInitialized)
XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic])
}
func testInitializesCoreWithSavedCustomRelayConfiguration() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
preferences.setRelayConfiguration(configuration)
_ = makeModel(core, preferences: preferences)
await waitUntil { core.state.isInitialized }
XCTAssertEqual(core.initializedNetworkConfigurations, [configuration])
}
func testSelectDestination() {
let model = makeModel(FakeCoreGateway(), preferences: Fixtures.preferences())
XCTAssertEqual(model.destination, .send)
model.selectDestination(.settings)
XCTAssertEqual(model.destination, .settings)
model.selectDestination(.settings) // no-op guard
XCTAssertEqual(model.destination, .settings)
}
func testThemeModeFollowsPreferences() async {
let prefs = Fixtures.preferences()
let model = makeModel(FakeCoreGateway(), preferences: prefs)
prefs.setThemeMode(.dark)
await waitUntil { model.themeMode == .dark }
XCTAssertEqual(model.themeMode, .dark)
}
}

View File

@@ -0,0 +1,87 @@
import XCTest
@testable import VniDrop
/// Ports `preferences/AppPreferencesRepositoryTest.kt` values persist to the
/// backing store and reload identically.
@MainActor
final class AppPreferencesRepositoryTests: XCTestCase {
private func defaults() -> UserDefaults { UserDefaults(suiteName: "vnidrop.prefs.\(UUID().uuidString)")! }
private func fallback() -> AppPreferencesDefaults {
AppPreferencesDefaults(
username: "Default",
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
themeMode: .system
)
}
func testMissingRelayProfileDefaultsToAutomatic() {
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
XCTAssertEqual(repo.preferences.username, "Default")
XCTAssertEqual(repo.preferences.themeMode, .system)
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
}
func testValuesPersistAndReload() {
let store = defaults()
let fb = fallback()
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
repo.setUsername("Bob")
repo.setThemeMode(.dark)
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
repo.setRelayConfiguration(RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
))
// A fresh repository over the same store reflects the persisted values.
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
XCTAssertEqual(reloaded.preferences.username, "Bob")
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
))
XCTAssertNotNil(store.data(forKey: "relay_configuration"))
XCTAssertNil(store.object(forKey: "relay_mode"))
XCTAssertNil(store.object(forKey: "relay_urls"))
}
func testCorruptedRelayProfileFailsClosed() {
let store = defaults()
store.set(Data("{".utf8), forKey: "relay_configuration")
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
XCTAssertEqual(
repo.preferences.relayConfiguration,
RelayConfiguration(mode: .strictCustom, relayURLs: [])
)
}
func testUnknownRelayModeFailsClosed() {
let store = defaults()
store.set(
Data(#"{"mode":"future-mode","relayURLs":["https://relay.example"]}"#.utf8),
forKey: "relay_configuration"
)
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
XCTAssertEqual(
repo.preferences.relayConfiguration,
RelayConfiguration(mode: .strictCustom, relayURLs: [])
)
}
func testResetReceiveFolderRestoresFallback() {
let store = defaults()
let fb = fallback()
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
repo.setReceiveFolder(ReceiveFolder(kind: .fileSystemPath, value: "/custom", displayName: "Custom"))
repo.resetReceiveFolder()
XCTAssertEqual(repo.preferences.receiveFolder.value, "/tmp")
}
}

View File

@@ -0,0 +1,68 @@
import XCTest
import Combine
@testable import VniDrop
/// Ports `feature/approvals/ApprovalCoordinatorTest.kt` (the gateway-observable
/// parts; notification assertions require a notification-service seam we don't
/// have on Apple yet).
@MainActor
final class ApprovalCoordinatorTests: XCTestCase {
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
ApprovalCoordinator(
repository: core,
notifications: LocalNotificationService(),
visibility: AppVisibility(),
messages: UiMessageController()
)
}
func testOrdersPendingRequestsByRequestedAt() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "new", requestedAt: 20),
Fixtures.request(id: "old", requestedAt: 10)]
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { !coordinator.state.pending.isEmpty }
XCTAssertEqual(coordinator.state.pending.map(\.id), ["old", "new"])
XCTAssertEqual(coordinator.state.current?.id, "old")
}
func testFailedResponseKeepsRequestVisibleAndClearsResponding() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
core.responseResult = .failure(TestError.unimplemented)
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { coordinator.state.pending.contains { $0.id == "request" } }
coordinator.accept("request")
await waitUntil { coordinator.state.respondingIds.isEmpty && core.responses.count == 1 }
XCTAssertTrue(coordinator.state.pending.contains { $0.id == "request" })
XCTAssertTrue(coordinator.state.respondingIds.isEmpty)
XCTAssertEqual(core.responses.first?.accepted, true)
}
func testAcceptRespondsPositivelyAndSingleFlights() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { coordinator.state.current != nil }
coordinator.accept("request")
coordinator.accept("request") // second call must be ignored (single-flight)
await waitUntil { core.responses.count >= 1 }
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(core.responses.count, 1)
XCTAssertEqual(core.responses.first?.id, "request")
}
}

View File

@@ -0,0 +1,50 @@
import XCTest
@testable import VniDrop
final class CoreDispatcherTests: XCTestCase {
/// Regression guard for the receive-cancel deadlock: an interrupt-lane call
/// must complete even while the serial lane is occupied by a blocking call.
/// With a single shared queue (the old design) the interrupt would be stuck
/// behind the blocked `receive`, and this would time out.
func testInterruptCompletesWhileSerialLaneIsBlocked() async {
let dispatcher = CoreDispatcher()
let serialEntered = DispatchSemaphore(value: 0)
let releaseSerial = DispatchSemaphore(value: 0)
// Occupy the serial lane with a call that blocks until we release it.
let serialTask = Task {
await dispatcher.run {
serialEntered.signal()
releaseSerial.wait()
}
}
XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started")
// The interrupt lane must run despite the serial lane being blocked.
let interruptDone = DispatchSemaphore(value: 0)
Task.detached {
_ = await dispatcher.runInterrupt { 42 }
interruptDone.signal()
}
XCTAssertEqual(
interruptDone.wait(timeout: .now() + 2), .success,
"interrupt lane was blocked behind the occupied serial lane")
releaseSerial.signal()
_ = await serialTask.value
}
func testRunPropagatesValuesAndErrors() async {
let dispatcher = CoreDispatcher()
let value = await dispatcher.run { 7 }
XCTAssertEqual(try? value.get(), 7)
let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented }
switch failure {
case .success: XCTFail("expected the thrown error to propagate")
case .failure(let error): XCTAssertTrue(error is TestError)
}
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import XCTest
@preconcurrency import VnidropCore
@testable import VniDrop
private enum BlockingCoreFactoryError: Error {
case stopped
}
private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked Sendable {
private let release = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var initializeCallCount = 0
private var initializationStarted = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
var callCount: Int {
lock.lock()
defer { lock.unlock() }
return initializeCallCount
}
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
lock.lock()
initializeCallCount += 1
let call = initializeCallCount
initializationStarted = true
let waiters = startWaiters
startWaiters.removeAll()
lock.unlock()
waiters.forEach { $0.resume() }
if call == 1 {
release.wait()
}
throw BlockingCoreFactoryError.stopped
}
func waitUntilInitializationStarts() async {
await withCheckedContinuation { continuation in
lock.lock()
if initializationStarted {
lock.unlock()
continuation.resume()
} else {
startWaiters.append(continuation)
lock.unlock()
}
}
}
func unblockInitialization() {
release.signal()
}
}
@MainActor
final class CoreRepositoryLifecycleTests: XCTestCase {
func testIdleRequirementRejectsTransfersAndShares() throws {
XCTAssertNoThrow(try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 0))
XCTAssertThrowsError(
try CoreNetworkLifecycle.requireIdle(activeTransfers: 1, activeShares: 0)
) { error in
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
}
XCTAssertThrowsError(
try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 1)
) { error in
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
}
}
func testRestartSerializesInitializationAndRejectsNewNetworkWork() async {
let factory = BlockingCoreBindingFactory()
let repository = CoreRepository(coreFactory: factory)
let firstInitialization = Task {
await repository.initialize(appDataDir: "/tmp/first", networkConfiguration: .automatic)
}
await factory.waitUntilInitializationStarts()
let safetyRelease = Task.detached {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled else { return }
factory.unblockInitialization()
}
defer {
safetyRelease.cancel()
factory.unblockInitialization()
}
let concurrentInitialization = await repository.initialize(
appDataDir: "/tmp/second",
networkConfiguration: RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
)
assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress)
let share = await repository.shareSources(
[],
transferName: "Blocked",
senderName: "Tester",
accessPolicy: .requireApproval
)
assertLifecycleFailure(share, equals: .transitionInProgress)
let receive = await repository.receive(ticket: "ticket", outputDir: "/tmp", receiverName: "Tester")
assertLifecycleFailure(receive, equals: .transitionInProgress)
XCTAssertEqual(factory.callCount, 1)
factory.unblockInitialization()
guard case .failure(let error) = await firstInitialization.value else {
return XCTFail("The blocking factory should fail the first initialization")
}
XCTAssertTrue(error is BlockingCoreFactoryError)
}
private func assertLifecycleFailure<T>(
_ result: Result<T, Error>,
equals expected: CoreNetworkLifecycleError,
file: StaticString = #filePath,
line: UInt = #line
) {
guard case .failure(let error) = result else {
return XCTFail("Expected lifecycle failure \(expected)", file: file, line: line)
}
XCTAssertEqual(error as? CoreNetworkLifecycleError, expected, file: file, line: line)
}
}

150
apple/Tests/Fakes.swift Normal file
View File

@@ -0,0 +1,150 @@
import Foundation
import Combine
import VnidropCore
@testable import VniDrop
enum TestError: Error { case unimplemented }
/// In-memory `CoreGateway`, mirroring `support/Fakes.kt`'s `FakeCoreGateway`.
/// Lets model tests drive core state/signals and stub results without the FFI.
@MainActor
final class FakeCoreGateway: CoreGateway {
private let stateSubject = CurrentValueSubject<CoreState, Never>(CoreState())
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
var state: CoreState { stateSubject.value }
var statePublisher: AnyPublisher<CoreState, Never> { stateSubject.eraseToAnyPublisher() }
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Stubbed results
var requests: [UInt64: [ReceiverRequestModel]] = [:]
var responseResult: Result<Void, Error> = .success(())
var shareResult: Result<Share, Error> = .failure(TestError.unimplemented)
var inspectionResult: Result<TicketInspectionModel, Error> = .failure(TestError.unimplemented)
var receiveResult: Result<Void, Error> = .success(())
var cancelResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(())
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
var initializeResult: Result<Void, Error> = .success(())
var initializeResults: [Result<Void, Error>] = []
// Recorded calls
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
private(set) var deletedTransfers: [UInt64] = []
private(set) var cancelledTransfers: [UInt64] = []
private(set) var clearReceiveHistoryCount = 0
private(set) var receiveCount = 0
private(set) var lastReceiveTicket: String?
private(set) var lastReceiveReceiverName: String?
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
func setState(_ state: CoreState) { stateSubject.send(state) }
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
func initialize(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
initializedNetworkConfigurations.append(networkConfiguration)
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
guard case .success = result else { return result }
var s = stateSubject.value
s.isInitialized = true
stateSubject.send(s)
return .success(())
}
func shutdown() {}
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
lastShareAccessPolicy = accessPolicy
return shareResult
}
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { inspectionResult }
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
return receiveResult
}
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> {
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
return receiveResult
}
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
.success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0))
}
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
responses.append((requestId, accepted, reason))
return responseResult
}
func refresh() async -> Result<Void, Error> { .success(()) }
}
/// Minimal `FileSystemService` fake a writable path receive folder, no reveal.
@MainActor
final class FakeFileSystemService: FileSystemService {
var supportsCustomReceiveFolders = false
var folder = ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents")
func defaultReceiveFolder() -> ReceiveFolder { folder }
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
}
}
@MainActor
final class FakeDeviceInfoProvider: DeviceInfoProvider {
func load() async -> DeviceInfo {
DeviceInfo(deviceName: "Test Device", deviceModel: "TestModel",
operatingSystem: "TestOS 1.0", network: nil, batteryLevel: nil)
}
}
// MARK: - Factories
@MainActor
enum Fixtures {
static func preferences(username: String = "Tester") -> AppPreferencesRepository {
let defaults = UserDefaults(suiteName: "vnidrop.tests.\(UUID().uuidString)")!
return AppPreferencesRepository(
defaults: defaults,
fallback: AppPreferencesDefaults(
username: username,
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents"),
themeMode: .system
)
)
}
static func request(id: String, requestedAt: Int64, transferId: UInt64 = 1, status: ReceiverDeliveryStatus = .requested) -> ReceiverRequestModel {
ReceiverRequestModel(
id: id, transferId: transferId, remoteEndpointId: "endpoint-\(id)",
transferName: "Photos", receiverName: "Peer", receiverDeviceName: "Phone",
appVersion: "1.0", status: status, reason: nil,
requestedAt: requestedAt, respondedAt: nil, completedAt: nil
)
}
static func transfer(id: UInt64, direction: TransferDirection, status: TransferStatus) -> Transfer {
Transfer(
localId: "local-\(id)", transferId: id, direction: direction, status: status,
peerId: nil, transferName: "Photos", contentHash: nil, fileCount: 1, totalSize: 1024,
ticket: "ticket", accessPolicy: .requireApproval, createdAt: 0, updatedAt: 0
)
}
}
/// Polls `condition` on the main actor until true or `timeout` elapses. Used to
/// await the models' internal `Task`s, which XCTest can't join directly.
@MainActor
func waitUntil(timeout: TimeInterval = 2, _ condition: @escaping () -> Bool) async {
let deadline = Date().addingTimeInterval(timeout)
while !condition() && Date() < deadline {
try? await Task.sleep(nanoseconds: 5_000_000)
}
}

View File

@@ -0,0 +1,43 @@
import XCTest
@testable import VniDrop
/// Ports `feature/send/FilePreviewRepositoryTest.kt` persisted thumbnails,
/// restore pruned to live transfer ids, and removal.
@MainActor
final class FilePreviewRepositoryTests: XCTestCase {
/// Minimal bytes that pass the PNG magic-byte check.
private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
private func makeRepo() -> FilePreviewRepository {
FilePreviewRepository(appDataDir: NSTemporaryDirectory() + "previews-" + UUID().uuidString)
}
func testSaveStoresPreview() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: png)
XCTAssertEqual(repo.previews[1], png)
}
func testSaveRejectsNonImageBytes() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: Data("not an image".utf8))
XCTAssertNil(repo.previews[1])
}
func testRestorePrunesToActiveIds() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: png)
repo.save(transferId: 2, bytes: png)
repo.restore(activeTransferIds: [1])
XCTAssertEqual(repo.previews[1], png)
XCTAssertNil(repo.previews[2])
}
func testRemoveDeletesPreview() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: png)
repo.remove(transferId: 1)
XCTAssertNil(repo.previews[1])
}
}

View File

@@ -0,0 +1,43 @@
import XCTest
@testable import VniDrop
/// Ports `feature/receive/ExternalInvitationControllerTest.kt` + the `.vnd`
/// decode/filename helpers.
@MainActor
final class InvitationTests: XCTestCase {
func testValidateInvitationAcceptsValid() {
guard case .success(let raw) = validateInvitation("some-ticket") else { return XCTFail("expected success") }
XCTAssertEqual(raw, "some-ticket")
}
func testValidateRejectsEmpty() {
guard case .failure(let error) = validateInvitation(" \n ") else { return XCTFail("expected failure") }
XCTAssertTrue((error as? InvitationError) != nil)
}
func testValidateRejectsTooLarge() {
let big = String(repeating: "a", count: maxVniDropInvitationBytes + 1)
guard case .failure = validateInvitation(big) else { return XCTFail("expected failure") }
}
func testDecodeInvitationBytesRoundTrip() throws {
let text = "vnidrop://ticket-abc"
let decoded = try decodeInvitationBytes(Data(text.utf8))
XCTAssertEqual(decoded, text)
}
func testDecodeRejectsEmptyData() {
XCTAssertThrowsError(try decodeInvitationBytes(Data()))
}
func testDecodeRejectsInvalidUtf8() {
XCTAssertThrowsError(try decodeInvitationBytes(Data([0xFF, 0xFE, 0xFD])))
}
func testInvitationFileNameSanitizes() {
XCTAssertEqual(invitationFileName("My Photos"), "My-Photos.vnd")
XCTAssertEqual(invitationFileName(" "), "invitation.vnd")
XCTAssertTrue(invitationFileName("a/b:c*d").hasSuffix(".vnd"))
}
}

View File

@@ -0,0 +1,81 @@
import XCTest
@testable import VniDrop
/// Ports selected `shared/src/commonTest/.../ui/state` assertions to verify the
/// progress-derivation logic matches the Kotlin implementation.
final class ProgressDerivationTests: XCTestCase {
func testFormatBytes() {
XCTAssertEqual(formatBytes(0), "0 B")
XCTAssertEqual(formatBytes(1023), "1023 B")
XCTAssertEqual(formatBytes(1024), "1.0 KB")
XCTAssertEqual(formatBytes(1536), "1.5 KB")
XCTAssertEqual(formatBytes(1024 * 1024), "1.0 MB")
}
func testWindowClassThresholds() {
XCTAssertEqual(windowClassFor(width: 320), .phone)
XCTAssertEqual(windowClassFor(width: 599), .phone)
XCTAssertEqual(windowClassFor(width: 600), .tablet)
XCTAssertEqual(windowClassFor(width: 919), .tablet)
XCTAssertEqual(windowClassFor(width: 920), .desktop)
}
func testParseProgressPrefersExported() {
XCTAssertEqual(parseProgress("{\"exported\":50,\"file_size\":100}"), 0.5)
XCTAssertEqual(parseProgress("{\"downloaded\":25,\"total_size\":100}"), 0.25)
XCTAssertNil(parseProgress("{\"foo\":1}"))
XCTAssertEqual(parseProgress("{\"offset\":200,\"size\":100}"), 1.0) // clamped
}
func testFindStringSkipsNull() {
XCTAssertEqual(findString("{\"endpoint_id\":\"abc\"}", key: "endpoint_id"), "abc")
XCTAssertNil(findString("{\"endpoint_id\":null}", key: "endpoint_id"))
XCTAssertNil(findString("{\"endpoint_id\":123}", key: "endpoint_id"))
}
func testProgressForTransferUsesLatestNewestFirst() {
let events = [
event(phase: "import", kind: "copy-progress", json: "{\"exported\":30,\"file_size\":100}"),
event(phase: "import", kind: "started", json: "{}"),
]
let progress = progressForTransfer(events: events, transferId: 1)
XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
XCTAssertEqual(progress?.progress, 0.3)
}
func testReceiverCompletionAfterProgressIsTerminal() {
let events = [
receiverEvent(kind: "completed", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\"}"),
receiverEvent(kind: "progress", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"end_offset\":100}"),
receiverEvent(kind: "started", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"size\":100}"),
]
let progress = progressForReceiver(
events: events,
transferId: 1,
remoteEndpointId: "peer-a",
totalSizeHint: 100
)
XCTAssertEqual(progress?.kind, .completed)
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
XCTAssertEqual(progress?.progress, 1)
}
func testStatusLabelKeys() {
XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available)
XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving)
XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed)
}
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
CoreEventModel(
id: UUID().uuidString, timestamp: 0, scope: "transfer", transferId: 1,
direction: "send", phase: phase, kind: kind, dataJson: json
)
}
private func receiverEvent(kind: String, json: String) -> CoreEventModel {
event(phase: "transfer", kind: kind, json: json)
}
}

View File

@@ -0,0 +1,70 @@
import XCTest
@testable import VniDrop
/// Ports the receive-side state-machine assertions from `feature/ViewModelsTest.kt`.
@MainActor
final class ReceiveModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway) -> ReceiveModel {
ReceiveModel(
repository: core,
fileSystemService: FakeFileSystemService(),
preferences: Fixtures.preferences(),
messages: UiMessageController()
)
}
func testDeleteHistoryItemDeletesTerminalReceiveTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)]))
await waitUntil { model.coreState.transfers.contains { $0.transferId == 5 } }
model.requestDeleteHistoryItem(5)
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
model.confirmHistoryDelete()
// Must close immediately (not after the async delete) so the alert can't
// re-present on macOS.
XCTAssertNil(model.state.historyDeleteTarget)
await waitUntil { core.deletedTransfers.contains(5) }
XCTAssertEqual(core.deletedTransfers, [5])
XCTAssertNil(model.state.historyDeleteTarget)
}
func testClearHistoryCallsClearReceiveHistory() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)]))
await waitUntil { !model.coreState.transfers.isEmpty }
model.requestClearHistory()
XCTAssertEqual(model.state.historyDeleteTarget, .all)
model.confirmHistoryDelete()
await waitUntil { core.clearReceiveHistoryCount == 1 }
XCTAssertEqual(core.clearReceiveHistoryCount, 1)
XCTAssertNil(model.state.historyDeleteTarget)
}
func testDeleteHistoryItemIgnoresNonTerminalTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 9, direction: .receive, status: .receiving)]))
await waitUntil { !model.coreState.transfers.isEmpty }
model.requestDeleteHistoryItem(9)
XCTAssertNil(model.state.historyDeleteTarget) // in-flight receive can't be deleted from history
}
func testCancelActiveReceiveCancelsTheReceivingTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 7, direction: .receive, status: .receiving)]))
await waitUntil { !model.coreState.transfers.isEmpty }
model.cancelActiveReceive()
await waitUntil { core.cancelledTransfers.contains(7) }
XCTAssertEqual(core.cancelledTransfers, [7])
}
}

View File

@@ -0,0 +1,121 @@
import XCTest
@testable import VniDrop
final class RelayConfigurationTests: XCTestCase {
func testCustomFallbackValidatesAndPreservesItsMode() throws {
let result = try RelayConfigurationValidator.validate(
mode: .customWithDirectFallback,
relayURLs: ["https://relay.example/"]
)
XCTAssertEqual(
result,
RelayConfiguration(
mode: .customWithDirectFallback,
relayURLs: ["https://relay.example"]
)
)
}
func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws {
let retained = ["https://relay.example"]
let result = try RelayConfigurationValidator.validate(
mode: .localOnly,
relayURLs: ["not a URL"],
retainedRelayURLs: retained
)
XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained))
}
func testAutomaticModeIgnoresRelayDrafts() throws {
let result = try RelayConfigurationValidator.validate(
mode: .automatic,
relayURLs: ["not a URL"]
)
XCTAssertEqual(result, .automatic)
}
func testAutomaticModeRetainsPreviouslySavedRelayURLs() throws {
let result = try RelayConfigurationValidator.validate(
mode: .automatic,
relayURLs: ["not a URL"],
retainedRelayURLs: ["https://relay.example"]
)
XCTAssertEqual(result, RelayConfiguration(
mode: .automatic,
relayURLs: ["https://relay.example"]
))
}
func testCustomModeTrimsValidHTTPSRelayURLs() throws {
let result = try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: [" https://relay.example/ ", "https://backup.example:443"]
)
XCTAssertEqual(result, RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay.example", "https://backup.example"]
))
}
func testCustomModeIgnoresEmptyURLRows() throws {
let result = try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: ["", " ", "https://relay.example"]
)
XCTAssertEqual(result.relayURLs, ["https://relay.example"])
}
func testCustomModeRequiresAtLeastOneRelay() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL)
}
}
func testCustomModeRequiresHTTPS() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: ["http://relay.example"]
)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0))
}
}
func testCustomModeRejectsCredentialsQueryFragmentAndPath() {
let invalidURLs = [
"https://user:password@relay.example",
"https://relay.example?token=secret",
"https://relay.example#fragment",
"https://relay.example/custom/path",
"https://relay.example:0",
"https://relay.example:99999",
]
for relayURL in invalidURLs {
XCTAssertThrowsError(
try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [relayURL]),
"Expected \(relayURL) to be rejected"
) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0))
}
}
}
func testCustomModeRejectsNormalizedDuplicate() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: ["https://relay.example", "https://RELAY.example:443/"]
)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1))
}
}
func testCustomModeRejectsMoreThanEightRelays() {
let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map {
"https://relay-\($0).example"
}
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: relayURLs)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs)
}
}
}

View File

@@ -0,0 +1,82 @@
import XCTest
@testable import VniDrop
/// Ports the send-side state-machine assertions from `feature/ViewModelsTest.kt`.
@MainActor
final class SendModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway) -> SendModel {
SendModel(
repository: core,
fileSystemService: FakeFileSystemService(),
preferences: Fixtures.preferences(),
filePreviewRepository: FilePreviewRepository(appDataDir: NSTemporaryDirectory() + UUID().uuidString),
messages: UiMessageController()
)
}
func testOpenAndCloseTransferDetails() {
let model = makeModel(FakeCoreGateway())
model.openTransfer(3)
XCTAssertEqual(model.state.selectedTransferId, 3)
model.closeTransferDetails()
XCTAssertNil(model.state.selectedTransferId)
}
func testDeleteTransferConfirmationFlow() async {
let core = FakeCoreGateway()
let model = makeModel(core)
model.openTransfer(3)
model.requestDeleteTransfer()
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
model.confirmDeleteTransfer()
// Must close immediately (not after the async delete) so the alert can't
// re-present on macOS.
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
await waitUntil { core.deletedTransfers.contains(3) }
XCTAssertEqual(core.deletedTransfers, [3])
XCTAssertNil(model.state.selectedTransferId)
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
}
func testStopSharingCancelsTheTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
model.stopSharing(transferId: 4)
await waitUntil { core.cancelledTransfers.contains(4) }
XCTAssertEqual(core.cancelledTransfers, [4])
}
func testCancelReceiverRefusesTheRequest() async {
let core = FakeCoreGateway()
let model = makeModel(core)
model.openTransfer(1)
model.cancelReceiver(requestId: "req-1")
await waitUntil { core.responses.contains { $0.id == "req-1" } }
let response = core.responses.first { $0.id == "req-1" }
XCTAssertEqual(response?.accepted, false)
}
func testOnlyActiveShareExposesStoredInvitationTicket() {
XCTAssertEqual(
Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation,
.ready("ticket")
)
XCTAssertEqual(
Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation,
.preparing
)
for status in [TransferStatus.stopped, .failed, .cancelled, .done] {
XCTAssertEqual(
Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation,
.unavailable
)
}
}
func testOversizedInvitationReportsQRCodeUnavailable() {
XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000)))
}
}

View File

@@ -0,0 +1,148 @@
import XCTest
@testable import VniDrop
/// Ports settings assertions from `feature/ViewModelsTest.kt` username debounce
/// persistence and the Storage "delete all transfers" flow.
@MainActor
final class SettingsModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> SettingsModel {
SettingsModel(
environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()),
deviceInfoProvider: FakeDeviceInfoProvider(),
fileSystemService: FakeFileSystemService(),
repository: core,
preferences: preferences,
notifications: LocalNotificationService(),
messages: UiMessageController(),
bugReports: NoopBugReportService(),
diagnosticsIncluded: false
)
}
func testUsernameChangeDebouncesAndPersists() async {
let prefs = Fixtures.preferences(username: "Original")
let model = makeModel(FakeCoreGateway(), preferences: prefs)
model.setUsername("Alice")
XCTAssertEqual(model.state.username, "Alice") // immediate local echo
await waitUntil { prefs.preferences.username == "Alice" } // persisted after debounce
XCTAssertEqual(prefs.preferences.username, "Alice")
}
func testDeleteAllTransfersDeletesEveryTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core, preferences: Fixtures.preferences())
core.setState(CoreState(isInitialized: true, transfers: [
Fixtures.transfer(id: 2, direction: .send, status: .sharing),
Fixtures.transfer(id: 3, direction: .receive, status: .done),
]))
model.deleteAllTransfers()
await waitUntil { core.deletedTransfers.count == 2 }
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
}
func testNetworkSettingsExposeCurrentEndpointId() {
let core = FakeCoreGateway()
let model = makeModel(core, preferences: Fixtures.preferences())
core.setState(CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0)
))
XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist")
}
func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
model.setRelayMode(.strictCustom)
model.setRelayURL(" https://relay.example/ ", at: 0)
model.applyRelayConfiguration()
await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom }
let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
XCTAssertEqual(preferences.preferences.relayConfiguration, expected)
XCTAssertEqual(core.initializedNetworkConfigurations, [expected])
XCTAssertFalse(model.state.relayConfigurationIsDirty)
}
func testApplyingAutomaticRetainsLastCustomRelayURLs() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let relayURLs = ["https://relay.example", "https://backup.example"]
preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs))
let model = makeModel(core, preferences: preferences)
model.setRelayMode(.automatic)
model.applyRelayConfiguration()
await waitUntil { preferences.preferences.relayConfiguration.mode == .automatic }
XCTAssertEqual(preferences.preferences.relayConfiguration.relayURLs, relayURLs)
XCTAssertEqual(core.initializedNetworkConfigurations, [
RelayConfiguration(mode: .automatic, relayURLs: relayURLs),
])
model.setRelayMode(.strictCustom)
XCTAssertEqual(model.state.relayURLs, relayURLs)
}
func testApplyRelayIsBlockedWhileShareIsActive() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
core.setState(CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1)
))
model.setRelayMode(.strictCustom)
model.setRelayURL("https://relay.example", at: 0)
model.applyRelayConfiguration()
await Task.yield()
XCTAssertTrue(core.initializedNetworkConfigurations.isEmpty)
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
}
func testRepositoryActiveWorkRejectionDoesNotAttemptRollback() async {
let core = FakeCoreGateway()
core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork)
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
model.setRelayMode(.strictCustom)
model.setRelayURL(attempted.relayURLs[0], at: 0)
model.applyRelayConfiguration()
await waitUntil {
core.initializedNetworkConfigurations.count == 1 && !model.state.isApplyingRelayConfiguration
}
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted])
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertTrue(model.state.hasActiveNetworkWork)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
}
func testFailedRelayApplyRollsBackWithoutPersisting() async {
let core = FakeCoreGateway()
core.initializeResults = [.failure(TestError.unimplemented), .success(())]
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
model.setRelayMode(.strictCustom)
model.setRelayURL(attempted.relayURLs[0], at: 0)
model.applyRelayConfiguration()
await waitUntil { core.initializedNetworkConfigurations.count == 2 }
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted, .automatic])
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_failed")
}
}

View File

@@ -0,0 +1,51 @@
import XCTest
@testable import VniDrop
@MainActor
final class TransferNotificationTests: XCTestCase {
func testTransferNotificationsFireForTerminalStatesOnly() {
let transfers = [
Fixtures.transfer(id: 1, direction: .send, status: .failed),
Fixtures.transfer(id: 2, direction: .receive, status: .done),
Fixtures.transfer(id: 3, direction: .receive, status: .failed),
Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored
Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored
Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified
]
let planned = plannedTransferNotifications(transfers, published: [])
XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed])
XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"])
XCTAssertEqual(planned.first?.transferName, "Photos")
}
func testTransferNotificationsSkipAlreadyPublished() {
let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)]
XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty)
}
func testReceiverNotificationsFireOnlyForCompletedReceivers() {
let requests = [
Fixtures.request(id: "a", requestedAt: 1, status: .completed),
Fixtures.request(id: "b", requestedAt: 2, status: .accepted),
Fixtures.request(id: "c", requestedAt: 3, status: .requested),
]
let planned = plannedReceiverNotifications(requests, published: [])
XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"])
XCTAssertEqual(planned.first?.kind, .receiverCompleted)
XCTAssertEqual(planned.first?.receiver, "Peer")
XCTAssertEqual(planned.first?.transferName, "Photos")
}
func testReceiverNotificationsSkipAlreadyPublished() {
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
}
func testReceiverNotificationsFireForFailedReceivers() {
let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)]
let planned = plannedReceiverNotifications(requests, published: [])
XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"])
XCTAssertEqual(planned.first?.kind, .receiverFailed)
}
}

View File

@@ -0,0 +1,68 @@
import XCTest
import VnidropCore
@testable import VniDrop
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
@MainActor
final class UiMessageControllerTests: XCTestCase {
func testQueuesAndAdvances() {
let c = UiMessageController()
c.show(UiMessage(text: .dynamic("first")))
c.show(UiMessage(text: .dynamic("second")))
XCTAssertEqual(c.current?.text, .dynamic("first"))
c.advance()
XCTAssertEqual(c.current?.text, .dynamic("second"))
c.advance()
XCTAssertNil(c.current)
}
func testErrorSuppressesUserCancellation() {
let c = UiMessageController()
c.error(InvitationError.cancelled)
XCTAssertNil(c.current) // cancellations are swallowed
}
func testErrorShowsNonCancellation() {
let c = UiMessageController()
c.error(InvitationError.raw("The transfer was refused"))
XCTAssertEqual(c.current?.tone, .error)
}
}
@MainActor
final class UserFacingErrorTests: XCTestCase {
func testIsUserCancellation() {
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
}
func testToUiTextMapsKnownReasons() {
// Typed cases map directly at the UI boundary.
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc))
// Dynamic `.raw` payloads still fall through the substring hints.
XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
}
func testToUiTextFallsBackToGeneric() {
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
}
func testToUiTextMapsTypedTransferFailures() {
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem))
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists))
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull))
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network))
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
/// Platform environment, ported from `Platform.kt` (`PlatformEnvironment`).
struct PlatformEnvironment {
let name: String
let appVersion: String
let defaultCoreDataDir: String
var defaultUsername: String = "Receiver"
}
/// Device info for diagnostics/about, ported from `DeviceInfo`.
struct DeviceInfo {
let deviceName: String?
let deviceModel: String?
let operatingSystem: String
let network: String?
let batteryLevel: String?
}
@MainActor
protocol DeviceInfoProvider {
func load() async -> DeviceInfo
}
/// Bundle of platform dependencies, ported from `AppDependencies`.
struct AppDependencies {
let environment: PlatformEnvironment
let deviceInfoProvider: DeviceInfoProvider
let fileSystemService: FileSystemService
let notificationService: LocalNotificationService
let externalInvitations: ExternalInvitationController
}

View File

@@ -0,0 +1,50 @@
import Foundation
import Combine
/// Object graph wiring the repositories and coordinators together, ported from
/// `AppGraph.kt`. Owned by the app root for the process lifetime.
@MainActor
final class AppGraph: ObservableObject {
let dependencies: AppDependencies
let coreRepository: CoreRepository
let visibility = AppVisibility()
let messages = UiMessageController()
let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies
let coreRepository = coreRepository ?? CoreRepository()
self.coreRepository = coreRepository
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
self.preferencesRepository = AppPreferencesRepository(
fallback: AppPreferencesDefaults(
username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system,
diagnosticsEnabled: false
)
)
self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.transferNotificationCoordinator = TransferNotificationCoordinator(
repository: coreRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
}
func close() {
coreRepository.shutdown()
}
}

View File

@@ -0,0 +1,250 @@
import SFSafeSymbols
import SwiftUI
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
/// the adaptive shell, floating actions, snackbar host, and approval modal.
struct RootView: View {
@StateObject private var graph: AppGraph
@StateObject private var appModel: AppModel
@StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel
@ObservedObject private var messages: UiMessageController
@ObservedObject private var approvals: ApprovalCoordinator
@Environment(\.scenePhase) private var scenePhase
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
@State private var showApproval = false
init(dependencies: AppDependencies) {
let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph)
_appModel = StateObject(wrappedValue: AppModel(
environment: dependencies.environment,
repository: graph.coreRepository,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_sendModel = StateObject(wrappedValue: SendModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
filePreviewRepository: graph.filePreviewRepository,
messages: graph.messages
))
_receiveModel = StateObject(wrappedValue: ReceiveModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_settingsModel = StateObject(wrappedValue: SettingsModel(
environment: dependencies.environment,
deviceInfoProvider: dependencies.deviceInfoProvider,
fileSystemService: dependencies.fileSystemService,
repository: graph.coreRepository,
preferences: graph.preferencesRepository,
notifications: dependencies.notificationService,
messages: graph.messages,
bugReports: NoopBugReportService()
))
messages = graph.messages
approvals = graph.approvalCoordinator
}
var body: some View {
GeometryReader { proxy in
let windowClass = windowClassFor(width: proxy.size.width)
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
SnackbarHost(controller: messages)
ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
)
}
.overlay {
// A small, unobtrusive indicator while the core finishes its async
// startup otherwise the lists look empty and the app feels stalled.
if !sendModel.coreState.isInitialized {
CoreStartingOverlay()
}
}
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
.vniDropTheme(isDark: isDark)
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
.environment(\.vniColors, isDark ? .dark : .light)
}
.platformPickers(settingsModel: settingsModel)
.task { await consumeExternalInvitations() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
graph.visibility.setForeground(true)
graph.backgroundActivity.didBecomeForeground()
settingsModel.refreshNotificationPermission()
// Reconcile against the durable snapshot: while the window was
// unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() }
case .background:
graph.visibility.setForeground(false)
// Hold the process open for iOS's grace window so an active
// transfer can finish and notify before suspension.
graph.backgroundActivity.didEnterBackground()
case .inactive:
graph.visibility.setForeground(false)
@unknown default:
break
}
}
// A pending approval is a blocking modal. Close the sender's detail panel
// (e.g. the Share/QR sheet) first, then present the approval sheet but on
// macOS a sheet presented while another is still dismissing is silently
// dropped, so defer the presentation until that dismissal finishes.
.onChange(of: approvals.state.current?.id) { _, id in
guard id != nil else { showApproval = false; return }
let wasShowingSheet = sendModel.state.detailPanel != nil
sendModel.closeDetailPanel()
#if os(macOS)
if wasShowingSheet {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
if approvals.state.current != nil { showApproval = true }
}
} else {
showApproval = true
}
#else
_ = wasShowingSheet
showApproval = true
#endif
}
#if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so
// drive foreground/background off NSApplication's active state instead
// otherwise notifications (only posted when unfocused) never fire.
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in
graph.visibility.setForeground(false)
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
graph.visibility.setForeground(true)
settingsModel.refreshNotificationPermission()
Task { _ = await graph.coreRepository.refresh() }
}
#endif
}
/// iOS uses a bottom tab bar; macOS uses a native source-list sidebar so each
/// screen's toolbar lives in the detail column instead of the shared title bar.
@ViewBuilder
private func navigation(windowClass: WindowClass) -> some View {
#if os(macOS)
NavigationSplitView {
List(AppDestination.allCases, selection: sidebarBinding) { destination in
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
.tag(destination)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
} detail: {
screen(for: appModel.destination, windowClass: windowClass)
}
#else
TabView(selection: destinationBinding) {
ForEach(AppDestination.allCases) { destination in
screen(for: destination, windowClass: windowClass)
.tabItem {
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
}
.tag(destination)
}
}
#endif
}
private var sidebarBinding: Binding<AppDestination?> {
Binding(
get: { appModel.destination },
set: { newValue in
if let value = newValue {
Task { @MainActor in appModel.selectDestination(value) }
}
}
)
}
private var destinationBinding: Binding<AppDestination> {
// Defer the write out of the current view-update cycle: TabView reconciles
// its selection synchronously during body evaluation on macOS, and mutating
// the published `destination` there triggers a "publishing within view
// updates" warning.
Binding(get: { appModel.destination }, set: { newValue in
Task { @MainActor in appModel.selectDestination(newValue) }
})
}
@ViewBuilder
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
}
}
private var systemDark: Bool {
#if os(iOS)
return UITraitCollection.current.userInterfaceStyle == .dark
#else
return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
#endif
}
private func consumeExternalInvitations() async {
for await invitation in graph.dependencies.externalInvitations.invitations {
appModel.selectDestination(.receive)
switch invitation {
case .success(let raw):
receiveModel.onInvitationResult(.invitationFile, .success(raw))
case .failure(let error):
receiveModel.onInvitationResult(.invitationFile, .failure(error))
}
}
}
}
/// A full-window cover with a centered spinner shown while the core is starting.
private struct CoreStartingOverlay: View {
var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
}
}
.transition(.opacity)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
}
private var backgroundColor: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color(nsColor: .windowBackgroundColor)
#endif
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,62 @@
import SwiftUI
/// Scene identifier for the single main window.
private let mainWindowId = "main"
/// Native app entry point for iOS, iPadOS, and macOS.
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
@main
struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController()
#if DIRECT_DISTRIBUTION && os(macOS)
// Sparkle auto-updater, present only in the direct-download (.dmg) build.
@StateObject private var updater = SparkleUpdaterController()
#endif
var body: some Scene {
#if os(macOS)
// A single-instance `Window` (not `WindowGroup`): the app must never open a
// second window. `Window` also drops the N "New Window" command.
Window(Text(verbatim: "VniDrop"), id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
.ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
#if DIRECT_DISTRIBUTION
.commands {
UpdatesCommands(controller: updater)
}
#endif
#else
WindowGroup(id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
.ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
#endif
}
/// Reads a `.vnd` invitation document under a security scope, enforcing the
/// 64 KiB / strict-UTF-8 rules from `ContentView.swift`.
private func openInvitation(_ url: URL) {
guard url.pathExtension.caseInsensitiveCompare(vniDropInvitationExtension) == .orderedSame else {
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
return
}
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
do {
let values = try url.resourceValues(forKeys: [.fileSizeKey])
if let size = values.fileSize, size > maxVniDropInvitationBytes {
throw InvitationError.tooLarge
}
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let raw = try decodeInvitationBytes(data)
externalInvitations.openInvitation(raw: raw)
} catch {
externalInvitations.reportOpenFailure(
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
)
}
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
import os
/// Minimal structured logger, ported from `logging/AppLogger.kt`. Never logs
/// tickets, endpoint ids, or file contents (callers pass only redacted fields).
enum AppLogger {
private static let logger = Logger(subsystem: "com.vnidrop.app", category: "app")
static func info(_ scope: String, _ message: String, _ fields: [String: String] = [:]) {
logger.info("[\(scope, privacy: .public)] \(message, privacy: .public) \(fieldString(fields), privacy: .public)")
}
static func error(_ scope: String, _ message: String, _ error: Error? = nil) {
if let error {
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public): \(error.technicalDetail, privacy: .public)")
} else {
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public)")
}
}
private static func fieldString(_ fields: [String: String]) -> String {
fields.isEmpty ? "" : fields.map { "\($0)=\($1)" }.joined(separator: " ")
}
}

View File

@@ -0,0 +1,242 @@
import Foundation
import Combine
/// Receive-destination descriptor, ported from `core/FileSystemService.kt`.
enum ReceiveFolderKind: String, Codable, Sendable {
case fileSystemPath
case iosSecurityScopedUrl
}
struct ReceiveFolder: Equatable, Codable, Sendable {
let kind: ReceiveFolderKind
let value: String
let displayName: String
}
enum FolderAccessStatus {
case writable
case permissionRequired
case unavailable
}
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
case automatic
case strictCustom = "custom"
case customWithDirectFallback = "custom-with-direct-fallback"
case localOnly = "local-only"
var usesCustomRelayURLs: Bool {
self == .strictCustom || self == .customWithDirectFallback
}
}
struct RelayConfiguration: Equatable, Codable, Sendable {
var mode: RelayPreferenceMode
var relayURLs: [String]
static let automatic = RelayConfiguration(mode: .automatic, relayURLs: [])
}
enum RelayConfigurationValidationError: Error, Equatable, Sendable {
case missingURL
case tooManyURLs
case httpsRequired(index: Int)
case invalidURL(index: Int)
case duplicateURL(index: Int)
var urlIndex: Int? {
switch self {
case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index
case .missingURL, .tooManyURLs: return nil
}
}
}
enum RelayConfigurationValidator {
static let maximumRelayCount = 8
static let maximumRelayURLBytes = 2_048
static func validate(
mode: RelayPreferenceMode,
relayURLs: [String],
retainedRelayURLs: [String] = []
) throws -> RelayConfiguration {
guard mode.usesCustomRelayURLs else {
return RelayConfiguration(mode: mode, relayURLs: retainedRelayURLs)
}
let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : (index, trimmed)
}
guard !relayEntries.isEmpty else {
throw RelayConfigurationValidationError.missingURL
}
guard relayEntries.count <= maximumRelayCount else {
throw RelayConfigurationValidationError.tooManyURLs
}
var seen = Set<String>()
var normalizedURLs: [String] = []
for (index, relayURL) in relayEntries {
guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes,
relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil,
var components = URLComponents(string: relayURL) else {
throw RelayConfigurationValidationError.invalidURL(index: index)
}
guard components.scheme?.lowercased() == "https" else {
throw RelayConfigurationValidationError.httpsRequired(index: index)
}
guard
let host = components.host,
!host.isEmpty,
components.port.map({ (1...65_535).contains($0) }) ?? true,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/"
else {
throw RelayConfigurationValidationError.invalidURL(index: index)
}
components.scheme = "https"
components.host = host.lowercased()
if components.port == 443 { components.port = nil }
if components.path == "/" { components.path = "" }
guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else {
throw RelayConfigurationValidationError.duplicateURL(index: index)
}
normalizedURLs.append(canonicalURL)
}
return RelayConfiguration(mode: mode, relayURLs: normalizedURLs)
}
}
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
struct AppPreferences: Equatable {
var username: String
var receiveFolder: ReceiveFolder
var themeMode: ThemeMode
var diagnosticsEnabled: Bool
var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
}
struct AppPreferencesDefaults {
let username: String
let receiveFolder: ReceiveFolder
let themeMode: ThemeMode
var diagnosticsEnabled: Bool = false
}
@MainActor
final class AppPreferencesRepository: ObservableObject {
@Published private(set) var preferences: AppPreferences
private let defaults: UserDefaults
private let fallback: AppPreferencesDefaults
private enum Key {
static let username = "username"
static let receiveFolderKind = "receive_folder_kind"
static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode"
static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration"
}
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
self.defaults = defaults
self.fallback = fallback
self.preferences = Self.read(from: defaults, fallback: fallback)
}
private static func read(from defaults: UserDefaults, fallback: AppPreferencesDefaults) -> AppPreferences {
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults)
)
}
private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration {
guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic }
guard
let data = defaults.data(forKey: Key.relayConfiguration),
let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data)
else {
// A stored profile must never silently fall back to public relays. Strict
// custom with no URLs makes startup fail closed until Settings repairs it.
return RelayConfiguration(mode: .strictCustom, relayURLs: [])
}
return configuration
}
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
let kind = defaults.string(forKey: Key.receiveFolderKind)
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
let value = defaults.string(forKey: Key.receiveFolderValue).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.value
let displayName = defaults.string(forKey: Key.receiveFolderDisplayName)
.flatMap { $0.isEmpty ? nil : $0 } ?? fallback.displayName
return ReceiveFolder(kind: kind, value: value, displayName: displayName)
}
private func reload() {
preferences = Self.read(from: defaults, fallback: fallback)
}
func setUsername(_ username: String) {
defaults.set(username.trimmingCharacters(in: .whitespacesAndNewlines), forKey: Key.username)
reload()
}
func setReceiveFolder(_ folder: ReceiveFolder) {
defaults.set(folder.kind.rawValue, forKey: Key.receiveFolderKind)
defaults.set(folder.value, forKey: Key.receiveFolderValue)
defaults.set(folder.displayName, forKey: Key.receiveFolderDisplayName)
reload()
}
func resetReceiveFolder() {
setReceiveFolder(fallback.receiveFolder)
}
func setThemeMode(_ mode: ThemeMode) {
defaults.set(mode.rawValue, forKey: Key.themeMode)
reload()
}
func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload()
}
func setRelayConfiguration(_ configuration: RelayConfiguration) {
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
defaults.set(encoded, forKey: Key.relayConfiguration)
reload()
}
@discardableResult
func ensureDiagnosticsInstallId() -> String {
let existing = preferences.diagnosticsInstallId
if !existing.isEmpty { return existing }
let created = UUID().uuidString
defaults.set(created, forKey: Key.diagnosticsInstallId)
reload()
return created
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import Combine
/// Tracks whether the app is in the foreground, ported from `platform/AppVisibility.kt`.
@MainActor
final class AppVisibility: ObservableObject {
@Published private(set) var isForeground: Bool = true
func setForeground(_ value: Bool) {
isForeground = value
}
}

View File

@@ -0,0 +1,77 @@
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
/// Keeps the Rust core alive across the app moving to the background, within the
/// bounds Apple actually allows for a serverless P2P transfer app.
///
/// iOS suspends the whole process (freezing the core's network threads) shortly
/// after the app leaves the foreground. When a transfer or share is active we
/// take a `UIApplication` background-task assertion so iOS grants its finite
/// grace window long enough for an in-flight transfer to finish streaming and
/// for its completion/failure notification to fire. There is no App-Store-legal
/// mechanism to keep serving or receiving *indefinitely* while backgrounded, and
/// `BGTaskScheduler` wake-ups run only opportunistically and cannot detect an
/// incoming peer connection, so they are deliberately not used here.
///
/// macOS does not suspend the process on focus loss, so this is a no-op there and
/// the core keeps running normally.
@MainActor
final class BackgroundActivityController {
private let repository: CoreRepository
init(repository: CoreRepository) {
self.repository = repository
}
#if os(iOS)
private var assertionId: UIBackgroundTaskIdentifier = .invalid
private var idleCancellable: AnyCancellable?
/// The app moved to the background. Hold the process open while there is live
/// work; release as soon as it drains, on return to foreground, or when iOS
/// ends the grace window (whichever comes first).
func didEnterBackground() {
guard assertionId == .invalid, hasActiveWork else { return }
assertionId = UIApplication.shared.beginBackgroundTask(withName: "vnidrop.transfer") { [weak self] in
// Expiration handler: iOS is reclaiming the window; end cleanly to
// avoid the watchdog terminating the app.
self?.endAssertion()
}
// Release the assertion the moment work finishes instead of holding it for
// the full window (battery, and it lets the process suspend sooner). Events
// still deliver on the main actor while the window is open, so the core's
// active counts drop here when a transfer completes.
idleCancellable = repository.statePublisher
.map { ($0.status?.activeTransfers ?? 0) == 0 && ($0.status?.activeShares ?? 0) == 0 }
.removeDuplicates()
.sink { [weak self] idle in
if idle { self?.endAssertion() }
}
}
/// The app returned to the foreground; the process is live again, so drop any
/// held assertion.
func didBecomeForeground() {
endAssertion()
}
private var hasActiveWork: Bool {
let status = repository.state.status
return (status?.activeTransfers ?? 0) > 0 || (status?.activeShares ?? 0) > 0
}
private func endAssertion() {
idleCancellable?.cancel()
idleCancellable = nil
guard assertionId != .invalid else { return }
UIApplication.shared.endBackgroundTask(assertionId)
assertionId = .invalid
}
#else
func didEnterBackground() {}
func didBecomeForeground() {}
#endif
}

View File

@@ -0,0 +1,41 @@
import Foundation
/// Dispatch-queue labels for the core's serial and interrupt lanes.
enum QueueLabel {
static let core = "com.vnidrop.core"
static let interrupt = "com.vnidrop.core.interrupt"
}
/// Two-lane dispatcher for blocking core calls.
///
/// `run` serializes calls on one queue so the core is driven from a single lane.
/// `runInterrupt` uses a *separate* concurrent lane, so an interrupt-style call
/// (cancel) can reach the core while a blocking call (`receive`) still occupies
/// the serial lane. The core is internally synchronized and explicitly supports
/// cancel arriving from another thread mid-receive (see VnidropCore.block_on);
/// a single shared queue would deadlock it.
final class CoreDispatcher: Sendable {
private let serialQueue: DispatchQueue
private let interruptQueue: DispatchQueue
init(label: String = QueueLabel.core, interruptLabel: String = QueueLabel.interrupt) {
serialQueue = DispatchQueue(label: label, qos: .userInitiated)
interruptQueue = DispatchQueue(label: interruptLabel, qos: .userInitiated, attributes: .concurrent)
}
/// Runs a blocking core call on the serial lane and hops the result back.
func run<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await withCheckedContinuation { continuation in
serialQueue.async { continuation.resume(returning: Result { try block() }) }
}
}
/// Like `run`, but off the serial lane so it can interrupt a blocking call in
/// flight there (e.g. cancel a `receive`). Only use for core calls that are
/// safe to run concurrently with another core call.
func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await withCheckedContinuation { continuation in
interruptQueue.async { continuation.resume(returning: Result { try block() }) }
}
}
}

View File

@@ -0,0 +1,50 @@
import Foundation
import Combine
import VnidropCore
struct CoreStorageUsageModel: Sendable {
let blobStoreBytes: UInt64
let appDataBytes: UInt64
}
struct ReceivedArtifactModel: Sendable {
let locator: String
let logicalSize: UInt64
}
/// Seam between the feature models and the Rust core, mirroring `CoreGateway`
/// in the KMP `shared` module. `CoreRepository` is the production implementation;
/// tests substitute a fake so the models can be exercised without the FFI.
@MainActor
protocol CoreGateway: AnyObject {
/// Latest published core state.
var state: CoreState { get }
/// Publisher of core-state changes (the models subscribe to this).
var statePublisher: AnyPublisher<CoreState, Never> { get }
/// Coalesced change hints emitted by the event sink.
var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func shutdown()
func shareSources(
_ sources: [ShareSource],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error>
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error>
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error>
func receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String
) async -> Result<Void, Error>
func cancel(transferId: UInt64) async -> Result<Void, Error>
func delete(transferId: UInt64) async -> Result<Void, Error>
func clearReceiveHistory() async -> Result<UInt64, Error>
func storageUsage() async -> Result<CoreStorageUsageModel, Error>
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error>
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() async -> Result<Void, Error>
}

View File

@@ -0,0 +1,188 @@
import Foundation
/// App-facing domain models, ported from `core/CoreModels.kt`. The repository maps
/// the generated UniFFI records/enums into these so the UI never depends on the
/// binding surface directly.
struct CoreStatus: Equatable, Sendable {
let endpointId: String
let activeTransfers: UInt64
let activeShares: UInt64
}
struct CoreEventModel: Equatable, Identifiable, Sendable {
let id: String
let timestamp: Int64
let scope: String
let transferId: UInt64?
/// Raw wire values as emitted by the core. Interpret them through the typed
/// `eventDirection` / `eventPhase` / `eventKind` accessors below logic code
/// should never compare these strings directly.
let direction: String?
let phase: String
let kind: String
let dataJson: String
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
var eventKind: EventKind? { EventKind(rawValue: kind) }
}
/// Direction of a core event, matching the wire strings the core emits.
enum EventDirection: String, Equatable, Sendable {
case send
case receive
}
/// Phase of a core progress event (the `phase` wire field).
enum EventPhase: String, Equatable, Sendable {
case importing = "import"
case ticket
case access
case transfer
case download
case export
case lifecycle
case network
case handshake
case error
}
/// Kind of a core progress event (the `kind` wire field).
enum EventKind: String, Equatable, Sendable {
case started
case copyProgress = "copy-progress"
case copyDone = "copy-done"
case outboardProgress = "outboard-progress"
case done
case created
case progress
case completed
case aborted
case failed
case connecting
case connected
case foundCollection = "found-collection"
case cancelled
case shareStopped = "share-stopped"
}
enum ShareAccessPolicy: Equatable, Sendable {
case requireApproval
case anyoneWithTransfer
}
enum TransferDirection: Equatable, Sendable {
case send
case receive
}
enum TransferStatus: Equatable, Sendable {
case importing
case sharing
case receiving
case done
case failed
case cancelled
case stopped
}
struct Transfer: Equatable, Identifiable, Sendable {
let localId: String
let transferId: UInt64
let direction: TransferDirection
let status: TransferStatus
let peerId: String?
let transferName: String?
let contentHash: String?
let fileCount: UInt64
let totalSize: UInt64
let ticket: String?
let accessPolicy: ShareAccessPolicy
let createdAt: Int64
let updatedAt: Int64
var id: String { localId }
}
struct Share: Equatable, Sendable {
let transferId: UInt64
let ticket: String
let transferName: String
let contentHash: String
let fileCount: UInt64
let totalSize: UInt64
}
struct TransferMetadataModel: Equatable, Sendable {
let transferId: UInt64
let transferName: String
let senderName: String?
let contentHash: String
let fileCount: UInt64
let totalSize: UInt64
}
struct TicketInspectionModel: Equatable, Sendable {
let kind: String
let metadata: TransferMetadataModel
}
enum ReceiverDeliveryStatus: Equatable, Sendable {
case requested
case accepted
case refused
case expired
case completed
case failed
case unknown
}
struct ReceiverRequestModel: Equatable, Identifiable, Sendable {
let id: String
let transferId: UInt64
let remoteEndpointId: String
let transferName: String
let receiverName: String?
let receiverDeviceName: String?
let appVersion: String
let status: ReceiverDeliveryStatus
let reason: String?
let requestedAt: Int64
let respondedAt: Int64?
let completedAt: Int64?
}
struct CoreState: Equatable, Sendable {
var isInitialized: Bool = false
var status: CoreStatus?
var events: [CoreEventModel] = []
var transfers: [Transfer] = []
var lastShare: Share?
var lastInspection: TicketInspectionModel?
}
/// Coalesced change hints emitted from the event sink, ported from `CoreSignal`.
enum CoreSignal: Equatable, Sendable {
case approvalChanged(transferId: UInt64)
case receiverHistoryChanged(transferId: UInt64)
/// Transfer status/history changed enough to re-read the durable snapshot.
case transfersChanged(transferId: UInt64)
}
// MARK: - Transfer helpers (ported from AppUiModels.kt)
extension TransferStatus {
var isActiveTransfer: Bool {
self == .importing || self == .sharing || self == .receiving
}
var canCancelTransfer: Bool {
self == .importing || self == .sharing || self == .receiving
}
/// Terminal receive-history states eligible for deletion.
var isTerminalReceiveHistory: Bool {
self == .done || self == .failed || self == .cancelled
}
}

View File

@@ -0,0 +1,521 @@
import Foundation
import Combine
@preconcurrency import VnidropCore
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
case transitionInProgress
case activeNetworkWork
var errorDescription: String? {
switch self {
case .transitionInProgress: return "A network restart is already in progress."
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
}
}
}
enum CoreNetworkLifecycle {
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
guard activeTransfers == 0, activeShares == 0 else {
throw CoreNetworkLifecycleError.activeNetworkWork
}
}
}
protocol CoreBindingFactory: Sendable {
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
}
struct NativeCoreBindingFactory: CoreBindingFactory {
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
let nativeConfiguration: CoreNetworkConfig
switch networkConfiguration.mode {
case .automatic:
nativeConfiguration = defaultCoreNetworkConfig()
case .strictCustom:
nativeConfiguration = CoreNetworkConfig(
mode: .strictCustom,
relayUrls: networkConfiguration.relayURLs
)
case .customWithDirectFallback:
nativeConfiguration = CoreNetworkConfig(
mode: .customWithDirectFallback,
relayUrls: networkConfiguration.relayURLs
)
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
networkConfig: nativeConfiguration
)
}
}
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
/// generated UniFFI records into app domain models, publishes an observable
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
///
/// UniFFI calls block (the core drives its own runtime via `block_on`), so they
/// run on a background queue and results are hopped back to the main actor.
@MainActor
final class CoreRepository: ObservableObject, CoreGateway {
@Published private(set) var state = CoreState()
var statePublisher: AnyPublisher<CoreState, Never> { $state.eraseToAnyPublisher() }
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Initialization swaps happen on `queue`; shutdown and snapshot reads may also
// access the handle from the main actor. The underlying core is internally
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
private nonisolated(unsafe) var core: VnidropCore?
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
// and transition flag drive relay-aware (re)initialization.
private let dispatcher = CoreDispatcher()
private let coreFactory: any CoreBindingFactory
private var isNetworkTransitionInProgress = false
private lazy var sink = RepositoryEventSink { [weak self] event in
Task { @MainActor in self?.handle(event: event) }
}
private nonisolated static let maxEvents = 200
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
self.coreFactory = coreFactory
}
// MARK: - Lifecycle
func initialize(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
isNetworkTransitionInProgress = true
defer { isNetworkTransitionInProgress = false }
let result = await runCore { [sink] in
if let existing = self.core {
let status = existing.status()
try CoreNetworkLifecycle.requireIdle(
activeTransfers: status.activeTransfers,
activeShares: status.activeShares
)
existing.shutdown()
self.core = nil
}
let created = try self.coreFactory.initialize(
appDataDir: appDataDir,
eventSink: sink,
networkConfiguration: networkConfiguration
)
self.core = created
return created
}
switch result {
case .success:
self.refreshSnapshot()
self.state.isInitialized = true
return .success(())
case .failure(let error):
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
self.state = CoreState()
}
return .failure(error)
}
}
func shutdown() {
core?.shutdown()
core = nil
state = CoreState()
}
// MARK: - Share
func shareSources(
_ sources: [ShareSource],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
guard !sources.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
return await runCore {
let result = try self.requireCore().shareFiles(
sources: sources,
metadata: ShareMetadataInput(
transferId: Self.nextTransferId(),
transferName: transferName.isEmpty ? nil : transferName,
senderName: senderName.isEmpty ? nil : senderName,
accessMode: accessPolicy.toNative()
)
)
return result.toModel()
}.map { share in
self.refreshSnapshot()
self.state.lastShare = share
return share
}
}
// MARK: - Inspect / Receive
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> {
await runCore {
try self.requireCore().inspectTicket(ticket: ticket).toModel()
}.map { inspection in
self.state.lastInspection = inspection
return inspection
}
}
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try self.requireCore().receive(
ticket: ticket,
outputDir: outputDir,
receiverName: receiverName.isEmpty ? nil : receiverName
)
}.map { self.refreshSnapshot() }
}
/// Receive into a security-scoped directory URL, holding access while the core
/// streams (mirrors `receiveIntoSecurityScopedDirectory`).
func receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().receive(
ticket: ticket,
outputDir: outputDirectoryUrl,
receiverName: receiverName.isEmpty ? nil : receiverName
)
}
}.map { self.refreshSnapshot() }
}
// MARK: - Lifecycle actions
func cancel(transferId: UInt64) async -> Result<Void, Error> {
// Off the serial `queue`: a receive in flight is blocking it, and the
// cancel signal must reach the core to unblock that receive.
await runInterrupt {
try self.requireCore().cancelTransfer(transferId: transferId)
}.map { self.refreshSnapshot() }
}
func delete(transferId: UInt64) async -> Result<Void, Error> {
await runCore {
try self.requireCore().deleteTransfer(transferId: transferId)
}.map {
self.refreshSnapshot()
self.signalsSubject.send(.approvalChanged(transferId: transferId))
self.signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
}
}
func clearReceiveHistory() async -> Result<UInt64, Error> {
await runCore {
try self.requireCore().deleteReceiveHistory()
}.map { deleted in
self.refreshSnapshot()
return deleted
}
}
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
await runCore {
let usage = try self.requireCore().storageUsage()
return CoreStorageUsageModel(
blobStoreBytes: usage.blobStoreBytes,
appDataBytes: usage.databaseBytes + usage.logsBytes + usage.previewsBytes + usage.otherCoreBytes
)
}
}
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> {
await runCore {
try self.requireCore().listReceivedArtifacts().compactMap { artifact in
guard artifact.locatorKind == .filesystemPath else { return nil }
return ReceivedArtifactModel(locator: artifact.locator, logicalSize: artifact.logicalSize)
}
}
}
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
await runCore {
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }
}
}
func respondReceiverRequest(
requestId: String,
accepted: Bool,
reason: String? = nil
) async -> Result<Void, Error> {
await runCore {
try self.requireCore().respondReceiverRequest(requestId: requestId, accepted: accepted, reason: reason)
}
}
func refresh() async -> Result<Void, Error> {
// Read from the core off the main actor, then apply the snapshot on the
// main actor so `@Published state` is never mutated from `queue`.
await runCore { self.readSnapshot() }.map { snapshot in
if let snapshot { self.applySnapshot(snapshot) }
}
}
// MARK: - Event sink handling (ported from CoreRepository.sink)
private func handle(event: CoreEvent) {
let model = event.toModel()
var events = state.events
events.insert(model, at: 0)
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
state.events = events
guard let transferId = model.transferId else { return }
switch model.phase {
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
default: break
}
if model.shouldRefreshTransfers {
signalsSubject.send(.transfersChanged(transferId: transferId))
}
}
// MARK: - Internals
/// Snapshot of the values read from the core in one pass.
private struct CoreSnapshot: Sendable {
let status: CoreStatus
let transfers: [Transfer]
let events: [CoreEventModel]
}
/// Reads the current core state. Safe to call off the main actor (pure core
/// FFI reads); does not touch `@Published` state.
private nonisolated func readSnapshot() -> CoreSnapshot? {
guard let core = self.core else { return nil }
let status = core.status()
let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? []
let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? []
return CoreSnapshot(
status: CoreStatus(
endpointId: status.endpointId,
activeTransfers: status.activeTransfers,
activeShares: status.activeShares
),
transfers: transfers,
events: Array(events)
)
}
/// Applies a snapshot to `@Published state`. Must run on the main actor.
private func applySnapshot(_ snapshot: CoreSnapshot) {
state.status = snapshot.status
state.transfers = snapshot.transfers
state.events = snapshot.events
}
private func refreshSnapshot() {
if let snapshot = readSnapshot() { applySnapshot(snapshot) }
}
private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else {
throw InvitationError.coreNotInitialized
}
return core
}
/// Runs a blocking core call off the main actor and hops the result back.
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await dispatcher.run(block)
}
/// Like `runCore`, but off the serial lane so it can interrupt a blocking
/// call in flight there (e.g. cancel a `receive`). Only use for core calls
/// that are safe to run concurrently with another core call.
private nonisolated func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await dispatcher.runInterrupt(block)
}
private nonisolated static func nextTransferId() -> UInt64 {
UInt64.random(in: 1...UInt64(Int64.max))
}
}
/// Event sink bridged to the repository. `onEvent` is invoked on core-owned
/// threads; the handler hops to the main actor.
private final class RepositoryEventSink: CoreEventSink, @unchecked Sendable {
private let handler: @Sendable (CoreEvent) -> Void
init(handler: @escaping @Sendable (CoreEvent) -> Void) { self.handler = handler }
func onEvent(event: CoreEvent) { handler(event) }
}
/// Runs `body` while holding security-scoped access to a bookmarked URL/path.
private func withSecurityScopedAccess<T>(pathOrUrl: String, _ body: () throws -> T) throws -> T {
let url = URL(string: pathOrUrl) ?? URL(fileURLWithPath: pathOrUrl)
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
return try body()
}
// MARK: - Mapping (ported from CoreRepository.kt)
private extension CoreEvent {
func toModel() -> CoreEventModel {
CoreEventModel(
id: id, timestamp: timestamp, scope: scope, transferId: transferId,
direction: direction, phase: phase, kind: kind, dataJson: dataJson
)
}
}
private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
private let refreshKinds: Set<EventKind> = [
.started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
]
private extension CoreEventModel {
var shouldRefreshTransfers: Bool {
guard let eventPhase, let eventKind else { return false }
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
}
}
extension ShareAccessPolicy {
func toNative() -> TransferAccessMode {
switch self {
case .requireApproval: return .approvalRequired
case .anyoneWithTransfer: return .public
}
}
}
private extension TransferAccessMode {
func toModel() -> ShareAccessPolicy {
switch self {
case .approvalRequired: return .requireApproval
case .public: return .anyoneWithTransfer
}
}
}
private extension StoredTransfer {
func toModel() -> Transfer {
Transfer(
localId: localId,
transferId: transferId,
direction: Self.direction(direction),
status: Self.status(status),
peerId: peerId,
transferName: transferName,
contentHash: contentHash,
fileCount: fileCount,
totalSize: totalSize,
ticket: ticket,
accessPolicy: accessMode.toModel(),
createdAt: createdAt,
updatedAt: updatedAt
)
}
static func direction(_ raw: String) -> TransferDirection {
switch raw {
case "send": return .send
case "receive": return .receive
default: return .send
}
}
static func status(_ raw: String) -> TransferStatus {
switch raw {
case "importing": return .importing
case "sharing": return .sharing
case "receiving": return .receiving
case "done": return .done
case "failed": return .failed
case "cancelled": return .cancelled
case "stopped": return .stopped
default: return .failed
}
}
}
private extension ShareResult {
func toModel() -> Share {
Share(
transferId: transferId, ticket: ticket, transferName: transferName,
contentHash: hash, fileCount: fileCount, totalSize: totalSize
)
}
}
private extension TicketInspection {
func toModel() -> TicketInspectionModel {
TicketInspectionModel(kind: kind, metadata: metadata.toModel())
}
}
private extension TransferMetadata {
func toModel() -> TransferMetadataModel {
TransferMetadataModel(
transferId: transferId, transferName: transferName, senderName: senderName,
contentHash: contentHash, fileCount: fileCount, totalSize: totalSize
)
}
}
private extension ReceiverRequest {
func toModel() -> ReceiverRequestModel {
ReceiverRequestModel(
id: id, transferId: transferId, remoteEndpointId: remoteEndpointId,
transferName: transferName, receiverName: receiverName, receiverDeviceName: receiverDeviceName,
appVersion: appVersion, status: Self.status(status), reason: reason,
requestedAt: requestedAt, respondedAt: respondedAt, completedAt: completedAt
)
}
static func status(_ raw: String) -> ReceiverDeliveryStatus {
switch raw {
case "requested": return .requested
case "accepted": return .accepted
case "refused": return .refused
case "expired": return .expired
case "completed": return .completed
case "failed": return .failed
default: return .unknown
}
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
let vniDropInvitationMimeType = "application/vnd.vnidrop.transfer"
let vniDropInvitationExtension = "vnd"
let maxVniDropInvitationBytes = 64 * 1024
/// Buffered ingress for invitation documents opened by the OS, ported from
/// `ExternalInvitationController.kt`. Hosts can submit before the UI is attached
/// during a cold launch; each document is consumed exactly once.
@MainActor
final class ExternalInvitationController: ObservableObject {
/// Emits validated (or failed) invitations. The app-level receive workflow
/// consumes each exactly once.
private var continuation: AsyncStream<Result<String, Error>>.Continuation?
lazy var invitations: AsyncStream<Result<String, Error>> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
func openInvitation(raw: String) {
continuation?.yield(validateInvitation(raw))
}
func reportOpenFailure(message: String) {
continuation?.yield(.failure(InvitationError.raw(message)))
}
}
/// Semantic, UI-agnostic invitation/transfer failures. Cases carry no display
/// text: `Error.toUiText()` (UI layer) maps each case to a localized `L10n` key,
/// so there are no free-form English strings to keep in sync or substring-match.
/// `.raw` is the escape hatch for genuinely dynamic system/core messages (e.g. a
/// `CoreNFC` `localizedDescription` or a picker's failure reason), never shown
/// verbatim it is still routed through `reasonHints`.
enum InvitationError: LocalizedError {
case empty
case tooLarge
case invalidEncoding
case shareEmpty
case cancelled
case coreNotInitialized
case unsupportedOperation
case noWindowAvailable
case viewControllerUnavailable
case filesystemUnavailable
case invalidInvitationURL
case nfcUnavailable
case nfcFailed
case cameraUnavailable
case qrUnavailable
case bugReportingUnavailable
case selectionFailed
case deleteRecordsFailed
case raw(String)
/// Developer/log-facing only never surfaced to users. Derived from the case
/// so there are no hand-written English blobs; `.raw` passes its payload through.
var errorDescription: String? {
if case .raw(let reason) = self { return reason }
return String(describing: self)
}
}
func validateInvitation(_ raw: String) -> Result<String, Error> {
if raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .failure(InvitationError.empty)
}
if raw.utf8.count > maxVniDropInvitationBytes {
return .failure(InvitationError.tooLarge)
}
return .success(raw)
}
/// Decode invitation document bytes as strict UTF-8, ported from
/// `decodeInvitationBytes`. Rejects payloads that are not lossless UTF-8.
func decodeInvitationBytes(_ bytes: Data) throws -> String {
guard !bytes.isEmpty else { throw InvitationError.empty }
guard bytes.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
guard let text = String(data: bytes, encoding: .utf8), Data(text.utf8) == bytes else {
throw InvitationError.invalidEncoding
}
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw InvitationError.empty }
return text
}

View File

@@ -0,0 +1,63 @@
import Foundation
import VnidropCore
/// A file/folder selected for sharing, ported from `PickedShareFile` in
/// `core/FilePicker.kt`.
struct PickedShareFile: Equatable, Identifiable, Sendable {
let value: String
let displayName: String
var sizeBytes: UInt64? = nil
var thumbnailData: Data? = nil
/// App-owned picker copy that may be deleted after import or abandonment.
var isTemporaryCopy: Bool = false
/// When true, `value` is a directory (path or security-scoped folder URL).
var isDirectory: Bool = false
/// macOS sandbox: a security-scoped bookmark captured at pick time so access to
/// `value` can be re-acquired when the core imports the file (the picker's own
/// scope ends immediately). Nil on iOS (which copies into the container instead).
var securityScopeBookmark: Data? = nil
var id: String { value }
}
/// Receive-destination and share-source platform bridge, ported from
/// `core/FileSystemService.kt` and its iOS/desktop actuals.
@MainActor
protocol FileSystemService {
var supportsCustomReceiveFolders: Bool { get }
func defaultReceiveFolder() -> ReceiveFolder
func effectiveReceiveFolder(_ configured: ReceiveFolder) -> ReceiveFolder
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
/// Releases only app-owned picker copies; never deletes original user sources.
func discardPickedFiles(_ files: [PickedShareFile]) async
func sharePickedFiles(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error>
}
extension FileSystemService {
var supportsCustomReceiveFolders: Bool { true }
func effectiveReceiveFolder(_ configured: ReceiveFolder) -> ReceiveFolder {
supportsCustomReceiveFolders ? configured : defaultReceiveFolder()
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
.failure(InvitationError.unsupportedOperation)
}
func discardPickedFiles(_ files: [PickedShareFile]) async {}
}
extension ReceiveFolder {
var isFileSystemPath: Bool { kind == .fileSystemPath }
}

View File

@@ -0,0 +1,138 @@
import Foundation
import Combine
import UserNotifications
/// Notification permission state, ported from `NotificationPermission`.
enum NotificationPermission {
case notDetermined
case granted
case denied
case unsupported
}
struct LocalNotification {
let id: String
let title: String
let body: String
}
/// Presents notifications even while the app is active. Without a delegate the
/// system drops the banner when the app is frontmost very visible on macOS,
/// where the app window is usually open when a transfer completes.
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound, .list]
}
/// Handle a notification tap inside the running instance and bring the existing
/// window forward, rather than letting the default launch behavior surface (which
/// on macOS can spin up a second process). The approval/transfer UI is driven by
/// core state, so activating the window is enough to reveal a pending approval.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
#if os(macOS)
await MainActor.run {
NSApp.activate(ignoringOtherApps: true)
// Reopen/focus the single main window (activation triggers SwiftUI's
// reopen handling when it was closed).
for window in NSApp.windows where window.canBecomeMain {
window.makeKeyAndOrderFront(nil)
break
}
}
#endif
}
}
/// Local notification service backed by `UNUserNotificationCenter`.
@MainActor
final class LocalNotificationService: ObservableObject {
@Published private(set) var permission: NotificationPermission = .notDetermined
private let center = UNUserNotificationCenter.current()
private let presenter = NotificationPresenter()
init() {
center.delegate = presenter
// Seed the permission immediately so gating (approval/lifecycle
// notifications) never races a not-yet-refreshed `.notDetermined`.
Task { _ = await refreshPermission() }
}
func refreshPermission() async -> NotificationPermission {
let settings = await center.notificationSettings()
let mapped = Self.map(settings.authorizationStatus)
permission = mapped
return mapped
}
func requestPermission() async -> NotificationPermission {
do {
_ = try await center.requestAuthorization(options: [.alert, .sound, .badge])
} catch {
AppLogger.error("notifications", "authorization request failed", error)
}
return await refreshPermission()
}
func openSettings() async -> Result<Void, Error> {
#if os(iOS)
guard let url = URL(string: UIApplication.openSettingsURLString) else {
return .failure(NotificationError.settingsUnavailable)
}
let opened = await UIApplication.shared.open(url)
return opened ? .success(()) : .failure(NotificationError.settingsUnavailable)
#else
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
return .failure(NotificationError.settingsUnavailable)
}
NSWorkspace.shared.open(url)
return .success(())
#endif
}
@discardableResult
func publish(_ notification: LocalNotification) async -> Result<Void, Error> {
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: nil)
do {
try await center.add(request)
return .success(())
} catch {
return .failure(error)
}
}
func cancel(id: String) {
center.removePendingNotificationRequests(withIdentifiers: [id])
center.removeDeliveredNotifications(withIdentifiers: [id])
}
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
switch status {
case .authorized, .provisional, .ephemeral: return .granted
case .denied: return .denied
case .notDetermined: return .notDetermined
@unknown default: return .notDetermined
}
}
}
private enum NotificationError: LocalizedError {
case settingsUnavailable
var errorDescription: String? { "Could not open notification settings" }
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,291 @@
import Foundation
/// Window size classes, ported from `AppUiModels.kt`. Thresholds are in points,
/// matching the Compose dp thresholds.
enum WindowClass {
case phone
case tablet
case desktop
}
func windowClassFor(width: Double) -> WindowClass {
if width >= 920 { return .desktop }
if width >= 600 { return .tablet }
return .phone
}
/// A progress snapshot derived from core events. `label` is a localization key
/// resolved at the view layer.
struct TransferProgress: Equatable {
let transferId: UInt64?
let phase: EventPhase
let kind: EventKind
let labelKey: String.LocalizationValue
let progress: Double?
var detail: String? = nil
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
/// which needs a runtime count).
var label: String? = nil
}
func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
switch status {
case .importing: return L10n.Status.preparing
case .sharing: return L10n.Status.available
case .receiving: return L10n.Status.receiving
case .done: return L10n.Status.completed
case .cancelled: return L10n.Status.cancelled
case .stopped: return L10n.Status.stopped
case .failed: return L10n.Status.failed
}
}
/// Latest progress snapshot for a transfer. Events are newest-first. Only events
/// whose `phase` and `kind` map to known cases participate.
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
let relevant = events.filter { event in
event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
}
guard let latest = relevant.first, let phase = latest.eventPhase, let kind = latest.eventKind else { return nil }
let sizeHint = findKnownSize(events: events, transferId: transferId)
return TransferProgress(
transferId: transferId,
phase: phase,
kind: kind,
labelKey: humanProgressLabel(phase: phase, kind: kind),
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
detail: progressDetail(latest)
)
}
/// Live byte progress for one receiver on an outgoing share.
func progressForReceiver(
events: [CoreEventModel],
transferId: UInt64,
remoteEndpointId: String,
totalSizeHint: UInt64? = nil
) -> TransferProgress? {
if remoteEndpointId.isEmpty { return nil }
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
let receiverKinds: Set<EventKind> = [.started, .progress, .completed, .aborted]
let transferEvents = events.filter { event in
event.transferId == transferId
&& event.eventDirection == .send
&& event.eventPhase == .transfer
&& (event.eventKind.map(receiverKinds.contains) ?? false)
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
}
guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
if latestKind == .aborted {
return TransferProgress(
transferId: transferId, phase: .transfer, kind: .aborted,
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
)
}
// Events are newest-first, so a completed latest event is terminal even when
// progress/started events precede it it must show as Completed, not Sending.
if latestKind == .completed {
return TransferProgress(
transferId: transferId, phase: .transfer, kind: .completed,
labelKey: L10n.Progress.completed, progress: 1, detail: nil
)
}
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
return TransferProgress(
transferId: transferId, phase: .transfer, kind: latestKind,
labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest)
)
}
func formatBytes(_ size: UInt64) -> String {
var scaled = Double(size)
let units = ["B", "KB", "MB", "GB", "TB"]
var unitIndex = 0
while scaled >= 1024 && unitIndex < units.count - 1 {
scaled /= 1024
unitIndex += 1
}
if unitIndex == 0 {
return "\(size) \(units[unitIndex])"
}
let rounded = (scaled * 10).rounded() / 10
return "\(rounded) \(units[unitIndex])"
}
// MARK: - Internals (ported literally from AppUiModels.kt)
private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue {
switch (phase, kind) {
case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started):
return L10n.Progress.preparing
case (.importing, .done): return L10n.Progress.ready
case (.ticket, .created): return L10n.Progress.shareReady
case (.network, .connecting): return L10n.Progress.connecting
case (.network, .connected): return L10n.Progress.connected
case (.download, .foundCollection): return L10n.Progress.gettingReady
case (.download, .progress): return L10n.Progress.downloading
case (.export, .progress): return L10n.Progress.saving
case (.transfer, .progress): return L10n.Progress.sending
case (.transfer, .started): return L10n.Progress.connected
case (.transfer, .completed): return L10n.Progress.completed
case (.lifecycle, .done): return L10n.Progress.completed
case (.lifecycle, .cancelled): return L10n.Progress.cancelled
default:
if phase == .handshake { return L10n.Progress.requestingAccess }
if kind == .failed { return L10n.Progress.failed }
return L10n.Progress.working
}
}
private func progressDetail(_ event: CoreEventModel) -> String? {
let fileName = findString(event.dataJson, key: "file_name")
let current = findNumber(event.dataJson, key: "current_file_index").map { Int64($0) }
let totalFiles = findNumber(event.dataJson, key: "total_files").map { Int64($0) }
if let fileName, let current, let totalFiles, totalFiles > 0 {
return "\(fileName) (\(current + 1)/\(totalFiles))"
}
return fileName
}
func parseProgress(_ json: String, sizeHint: Double? = nil) -> Double? {
let transferred = findNumber(json, key: "exported")
?? findNumber(json, key: "downloaded")
?? findNumber(json, key: "offset")
?? findNumber(json, key: "end_offset")
?? findNumber(json, key: "transferred")
?? findNumber(json, key: "written")
let total = findNumber(json, key: "file_size")
?? findNumber(json, key: "total_size")
?? findNumber(json, key: "size")
?? findNumber(json, key: "total")
?? sizeHint
guard let transferred, let total, total > 0 else { return nil }
return min(1, max(0, transferred / total))
}
private func findKnownSize(events: [CoreEventModel], transferId: UInt64) -> Double? {
for event in events where event.transferId == transferId {
if let s = findNumber(event.dataJson, key: "size"), s > 0 { return s }
if let s = findNumber(event.dataJson, key: "total_size"), s > 0 { return s }
if let s = findNumber(event.dataJson, key: "file_size"), s > 0 { return s }
}
return nil
}
private final class BlobState {
var size: Double?
var offset: Double = 0
var completed = false
var aborted = false
}
private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint: UInt64?) -> Double? {
let chronological = events.reversed()
var byRequest = [String: BlobState]()
var order = [String]()
var connectionScopedOffset: Double?
var connectionScopedSize: Double?
for event in chronological {
let requestKey = findNumber(event.dataJson, key: "request_id").map { String(Int64($0)) }
?? findString(event.dataJson, key: "request_id")
let size = findNumber(event.dataJson, key: "size")
let endOffset = findNumber(event.dataJson, key: "end_offset")
?? findNumber(event.dataJson, key: "offset")
?? findNumber(event.dataJson, key: "transferred")
if let requestKey {
let state: BlobState
if let existing = byRequest[requestKey] {
state = existing
} else {
state = BlobState()
byRequest[requestKey] = state
order.append(requestKey)
}
if let size, size > 0 { state.size = size }
switch event.eventKind {
case .progress, .started:
if let endOffset { state.offset = max(state.offset, endOffset) }
state.aborted = false
case .completed:
state.completed = true
if let s = state.size { state.offset = s }
case .aborted:
state.aborted = true
default:
break
}
} else {
if let size, size > 0 { connectionScopedSize = size }
if let endOffset { connectionScopedOffset = endOffset }
}
}
if !byRequest.isEmpty {
let active = order.compactMap { byRequest[$0] }.filter { !$0.aborted }
if active.isEmpty { return nil }
let transferred = active.reduce(0.0) { acc, state in
acc + (state.completed ? (state.size ?? state.offset) : state.offset)
}
let observedSize = active.compactMap { $0.size }.reduce(0, +)
let total = totalSizeHint.map { Double($0) }.flatMap { $0 > 0 ? $0 : nil }
?? (observedSize > 0 ? observedSize : nil)
guard let total, total > 0 else { return nil }
return min(1, max(0, transferred / total))
}
let total = totalSizeHint.map { Double($0) }.flatMap { $0 > 0 ? $0 : nil }
?? connectionScopedSize.flatMap { $0 > 0 ? $0 : nil }
guard let transferred = connectionScopedOffset, let total, total > 0 else { return nil }
return min(1, max(0, transferred / total))
}
private func connectionIdsForEndpoint(events: [CoreEventModel], remoteEndpointId: String) -> Set<String> {
var ids = Set<String>()
for event in events {
guard let endpoint = findString(event.dataJson, key: "endpoint_id"), endpoint == remoteEndpointId else { continue }
if let n = findNumber(event.dataJson, key: "connection_id") { ids.insert(String(Int64(n))) }
if let s = findString(event.dataJson, key: "connection_id") { ids.insert(s) }
}
return ids
}
private func eventBelongsToReceiver(_ event: CoreEventModel, remoteEndpointId: String, connectionIds: Set<String>) -> Bool {
if let endpoint = findString(event.dataJson, key: "endpoint_id") {
return endpoint == remoteEndpointId
}
let connectionId = findNumber(event.dataJson, key: "connection_id").map { String(Int64($0)) }
?? findString(event.dataJson, key: "connection_id")
guard let connectionId else { return false }
return connectionIds.contains(connectionId)
}
// Lightweight JSON scraping, ported from AppUiModels.kt (matches core event shapes).
func findNumber(_ json: String, key: String) -> Double? {
let marker = "\"\(key)\":"
guard let range = json.range(of: marker) else { return nil }
let after = json[range.upperBound...].drop { $0 == " " }
if after.hasPrefix("null") { return nil }
let terminators: Set<Character> = [",", "}", "]"]
var value = ""
for ch in json[range.upperBound...] {
if terminators.contains(ch) { break }
value.append(ch)
}
let trimmed = value.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return Double(trimmed)
}
func findString(_ json: String, key: String) -> String? {
let marker = "\"\(key)\":"
guard let range = json.range(of: marker) else { return nil }
let after = json[range.upperBound...].drop { $0 == " " }
if after.hasPrefix("null") { return nil }
guard after.hasPrefix("\"") else { return nil }
let content = after.dropFirst()
guard let endIdx = content.firstIndex(of: "\"") else { return nil }
if content.startIndex == endIdx { return nil }
return String(content[content.startIndex..<endIdx])
}

View File

@@ -0,0 +1,47 @@
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
private let environment: PlatformEnvironment
private let repository: CoreGateway
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
environment: PlatformEnvironment,
repository: CoreGateway,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.environment = environment
self.repository = repository
self.messages = messages
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task {
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
)
if case .failure(let error) = result { messages.error(error) }
}
preferences.$preferences
.map(\.themeMode)
.removeDuplicates()
.sink { [weak self] mode in self?.themeMode = mode }
.store(in: &cancellables)
}
func selectDestination(_ destination: AppDestination) {
guard destination != self.destination else { return }
self.destination = destination
}
}

View File

@@ -0,0 +1,185 @@
import Foundation
import Combine
/// A pending receiver approval, ported from `feature/approvals/ApprovalCoordinator.kt`.
struct PendingApproval: Equatable, Identifiable {
let id: String
let transferId: UInt64
let transferName: String
let receiverName: String?
let receiverDeviceName: String?
/// Cryptographic peer identity not display-name spoofable.
let remoteEndpointId: String
let requestedAt: Int64
}
struct ApprovalState: Equatable {
var pending: [PendingApproval] = []
var respondingIds: Set<String> = []
var current: PendingApproval? { pending.first }
}
/// Drives receiver-approval prompts and their notifications, ported from
/// `ApprovalCoordinator.kt`.
@MainActor
final class ApprovalCoordinator: ObservableObject {
@Published private(set) var state = ApprovalState()
private let repository: CoreGateway
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
private var publishedNotificationIds = Set<String>()
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.notifications = notifications
self.visibility = visibility
self.messages = messages
repository.signals
.sink { [weak self] signal in
guard let self else { return }
if case .approvalChanged(let transferId) = signal {
Task { await self.refresh(transferId: transferId) }
}
}
.store(in: &cancellables)
repository.statePublisher
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
let sharing = core.transfers.filter { $0.direction == .send && $0.status == .sharing }
for transfer in sharing {
Task { await self.refresh(transferId: transfer.transferId) }
}
}
.store(in: &cancellables)
// Recompute notifications when any input changes.
Publishers.CombineLatest3(
visibility.$isForeground,
$state,
notifications.$permission
)
.sink { [weak self] foreground, approvalState, permission in
guard let self else { return }
Task {
await self.synchronizeNotifications(
foreground: foreground,
pending: approvalState.pending,
permission: permission
)
}
}
.store(in: &cancellables)
}
func accept(_ requestId: String) { respond(requestId, accepted: true) }
func refuse(_ requestId: String) { respond(requestId, accepted: false) }
private func respond(_ requestId: String, accepted: Bool) {
guard !state.respondingIds.contains(requestId) else { return }
state.respondingIds.insert(requestId)
Task {
let request = state.pending.first { $0.id == requestId }
let result = await repository.respondReceiverRequest(
requestId: requestId,
accepted: accepted,
reason: accepted ? nil : "sender-refused"
)
state.respondingIds.remove(requestId)
switch result {
case .success:
if let request { await refresh(transferId: request.transferId) }
case .failure(let error):
messages.error(error)
}
}
}
private func refresh(transferId: UInt64) async {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
let refreshed = requests
.filter { $0.status == .requested }
.map { $0.toPending() }
let refreshedIds = Set(refreshed.map { $0.id })
let removed = Set(state.pending.filter { $0.transferId == transferId }.map { $0.id })
.subtracting(refreshedIds)
for id in removed {
notifications.cancel(id: Self.notificationId(id))
publishedNotificationIds.remove(id)
}
var pending = state.pending.filter { $0.transferId != transferId } + refreshed
// distinctBy id, sorted by requestedAt
var seen = Set<String>()
pending = pending.filter { seen.insert($0.id).inserted }
.sorted { $0.requestedAt < $1.requestedAt }
state.pending = pending
case .failure(let error):
messages.error(error)
}
}
private func synchronizeNotifications(
foreground: Bool,
pending: [PendingApproval],
permission: NotificationPermission
) async {
// iOS suppresses notifications while the user is in the app (the modal shows
// instead); macOS presents them even when active (the app window is usually
// open), relying on the presenter delegate.
#if os(iOS)
let suppressed = foreground || permission != .granted
#else
let suppressed = permission != .granted
#endif
if suppressed {
// Cancel only our own approval notifications other coordinators
// (e.g. transfer-lifecycle) manage their own and must not be wiped.
for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) }
return
}
for request in pending where !publishedNotificationIds.contains(request.id) {
// Reserve the id *before* awaiting: the CombineLatest can fire several
// times near-simultaneously, and without this each pass re-adds the same
// notification identifier. macOS coalesces a repeated add of an in-flight
// id into a silent update and shows no banner.
publishedNotificationIds.insert(request.id)
let receiver = request.receiverName
?? request.receiverDeviceName
?? String(localized: L10n.Approval.nearbyDevice)
let title = String(localized: L10n.Approval.connectionRequest)
let body = L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName)
let result = await notifications.publish(
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
)
if case .failure(let error) = result {
publishedNotificationIds.remove(request.id)
messages.error(error)
}
}
}
private static func notificationId(_ requestId: String) -> String { "approval-\(requestId)" }
}
private extension ReceiverRequestModel {
func toPending() -> PendingApproval {
PendingApproval(
id: id, transferId: transferId, transferName: transferName,
receiverName: receiverName, receiverDeviceName: receiverDeviceName,
remoteEndpointId: remoteEndpointId, requestedAt: requestedAt
)
}
}

View File

@@ -0,0 +1,78 @@
import SwiftUI
import SFSafeSymbols
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
/// be swiped away. The endpoint id is the trusted identity; display names are
/// peer-provided.
struct ApprovalModalHost: View {
/// Driven by the host so presentation can be deferred until any competing sheet
/// (the Share/QR drawer) has finished dismissing macOS silently drops a sheet
/// presented while another is still animating out.
@Binding var isPresented: Bool
let state: ApprovalState
let onAccept: (String) -> Void
let onRefuse: (String) -> Void
var body: some View {
Color.clear
.sheet(isPresented: $isPresented) {
if let request = state.current {
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
.interactiveDismissDisabled(true)
.modifier(ApprovalDetents())
}
}
}
}
private struct ApprovalDetents: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
content.presentationDetents([.medium])
#else
content.frame(minWidth: 420, minHeight: 320)
#endif
}
}
private struct ApprovalSheet: View {
let state: ApprovalState
let request: PendingApproval
let onAccept: (String) -> Void
let onRefuse: (String) -> Void
var body: some View {
let busy = state.respondingIds.contains(request.id)
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: L10n.Approval.nearbyDevice)
VStack(spacing: 16) {
Image(systemSymbol: .checkmarkShieldFill)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Approval.connectionRequest))
.font(.title2).fontWeight(.semibold)
Text(L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName))
.multilineTextAlignment(.center)
Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId))
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if state.pending.count > 1 {
Text(L10n.Approval.pendingCount(count: state.pending.count))
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if busy { ProgressView() }
VStack(spacing: 10) {
Button(action: { onAccept(request.id) }) {
Text(String(localized: L10n.Button.approve)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
Button(role: .destructive, action: { onRefuse(request.id) }) {
Text(String(localized: L10n.Button.refuse)).frame(maxWidth: .infinity)
}
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
}
}
.padding(24)
}
}

View File

@@ -0,0 +1,198 @@
import Combine
import Foundation
/// A transfer-lifecycle moment worth a local notification.
enum TransferNotificationKind: Equatable {
case sendFailed // A share you own failed.
case receiveCompleted // An incoming transfer finished downloading.
case receiveFailed // An incoming transfer failed.
case receiverCompleted // A receiver finished downloading your shared transfer.
case receiverFailed // A receiver's download of your shared transfer failed.
}
/// A notification resolved from core state but not yet published. `transferName`
/// is the raw name (may be nil); the coordinator localizes and applies fallbacks.
struct PlannedNotification: Equatable {
let id: String
let kind: TransferNotificationKind
let transferName: String?
let receiver: String?
}
/// Pure: transfer-status notifications for this snapshot, excluding already-published
/// ids. A terminal transfer yields at most one notification, keyed by (kind, id).
func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String>) -> [PlannedNotification] {
transfers.compactMap { transfer in
let kind: TransferNotificationKind
switch (transfer.direction, transfer.status) {
case (.send, .failed): kind = .sendFailed
case (.receive, .done): kind = .receiveCompleted
case (.receive, .failed): kind = .receiveFailed
default: return nil
}
let id = transferNotificationId(kind, transferId: transfer.transferId)
guard !published.contains(id) else { return nil }
return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil)
}
}
/// Pure: one notification per receiver that has finished downloading a shared
/// transfer, excluding already-published ids.
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
requests.compactMap { request in
let kind: TransferNotificationKind
let idPrefix: String
switch request.status {
case .completed: kind = .receiverCompleted; idPrefix = "receiver-completed"
case .failed: kind = .receiverFailed; idPrefix = "receiver-failed"
default: return nil
}
let id = "\(idPrefix)-\(request.id)"
guard !published.contains(id) else { return nil }
return PlannedNotification(
id: id, kind: kind,
transferName: request.transferName,
receiver: request.receiverName ?? request.receiverDeviceName
)
}
}
private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String {
switch kind {
case .sendFailed: return "send-failed-\(transferId)"
case .receiveCompleted: return "receive-completed-\(transferId)"
case .receiveFailed: return "receive-failed-\(transferId)"
case .receiverCompleted: return "receiver-completed-\(transferId)"
case .receiverFailed: return "receiver-failed-\(transferId)"
}
}
/// Fires local notifications for transfer-lifecycle moments (a receive finishing
/// or failing, a share failing, a receiver completing), so a user who left the
/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`.
///
/// Gated on the OS notification permission (and, on iOS, on being backgrounded).
/// Each moment is terminal, so it is marked seen the first time it is observed and
/// never re-published. The first state snapshot which includes existing history
/// such as past receives only primes those ids as seen, so only new transitions
/// notify.
@MainActor
final class TransferNotificationCoordinator: ObservableObject {
private let repository: CoreGateway
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
private var published = Set<String>()
private var primedTransfers = false
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.notifications = notifications
self.visibility = visibility
self.messages = messages
repository.statePublisher
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
Task { await self.syncTransfers(core.transfers) }
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged:
break
}
}
.store(in: &cancellables)
}
/// iOS suppresses notifications while the user is in the app (the convention);
/// macOS presents them even when active (also the convention the app window
/// is usually open), relying on the presenter delegate to show the banner.
private var canPublish: Bool {
guard notifications.permission == .granted else { return false }
#if os(iOS)
return !visibility.isForeground
#else
return true
#endif
}
private func syncTransfers(_ transfers: [Transfer]) async {
let planned = plannedTransferNotifications(transfers, published: published)
guard primedTransfers else {
// The first snapshot includes existing history (e.g. past receives).
// Mark those terminal transfers seen without notifying, so only new
// transitions notify.
primedTransfers = true
for plan in planned { published.insert(plan.id) }
return
}
for plan in planned { await deliver(plan) }
}
private func syncReceivers(transferId: UInt64) async {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
for plan in plannedReceiverNotifications(requests, published: published) {
await deliver(plan)
}
case .failure(let error):
messages.error(error)
}
}
/// Mark seen unconditionally (a terminal moment notifies at most once), then
/// publish only when the gate allows.
private func deliver(_ plan: PlannedNotification) async {
published.insert(plan.id)
guard canPublish else { return }
let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
let notification: LocalNotification
switch plan.kind {
case .sendFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.sendFailedTitle),
body: L10n.Notifications.sendFailedBody(transferName: name))
case .receiveCompleted:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveCompletedTitle),
body: L10n.Notifications.receiveCompletedBody(transferName: name))
case .receiveFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveFailedTitle),
body: L10n.Notifications.receiveFailedBody(transferName: name))
case .receiverCompleted:
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverCompletedTitle),
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
case .receiverFailed:
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverFailedTitle),
body: L10n.Notifications.receiverFailedBody(receiver: receiver, transferName: name))
}
if case .failure(let error) = await notifications.publish(notification) {
messages.error(error)
}
}
}

View File

@@ -0,0 +1,150 @@
import SFSafeSymbols
import SwiftUI
enum ReceiveMethodAvailability { case available, unavailable, hidden }
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
@MainActor
protocol ReceiveInvitationActions: AnyObject {
var fileAvailability: ReceiveMethodAvailability { get }
var qrAvailability: ReceiveMethodAvailability { get }
var nfcAvailability: ReceiveMethodAvailability { get }
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void)
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void)
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void)
func cancel()
}
/// Method chooser panel, ported from `ReceiveMethodPanel` in `ReceiveScreen.kt`.
struct ReceiveMethodPanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: ReceiveModel
@State private var actions: ReceiveInvitationActions = makeReceiveInvitationActions()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
MethodRow(
icon: .doc, titleKey: L10n.Receive.methodFile, descKey: L10n.Receive.methodFileDescription,
availability: actions.fileAvailability
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
if actions.qrAvailability != .hidden {
MethodRow(
icon: .qrcodeViewfinder, titleKey: L10n.Receive.methodScan, descKey: L10n.Receive.methodScanDescription,
availability: actions.qrAvailability
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
}
if actions.nfcAvailability != .hidden {
MethodRow(
icon: .wave3Right,
titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil,
titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription,
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
) {
model.setWaitingForNfc(true)
actions.readNfcInvitation { model.onInvitationResult(.nfc, $0) }
}
}
}
.padding(.horizontal, 20).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct MethodRow: View {
@Environment(\.vniColors) private var colors
let icon: SFSymbol
var titleOverride: String? = nil
let titleKey: String.LocalizationValue
let descKey: String.LocalizationValue
let availability: ReceiveMethodAvailability
let onTap: () -> Void
var body: some View {
let enabled = availability == .available
Button(action: onTap) {
HStack(spacing: 14) {
Image(systemSymbol: icon).font(.system(size: 22))
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
.frame(width: 24)
VStack(alignment: .leading, spacing: 3) {
if let titleOverride {
Text(titleOverride).font(VniType.bodyLarge)
} else {
Text(String(localized: titleKey)).font(VniType.bodyLarge)
}
Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
Spacer()
if availability == .unavailable {
Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
}
}
.padding(16)
.frame(maxWidth: .infinity)
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
}
/// Invitation review panel, ported from `InvitationReviewPanel` in `ReceiveScreen.kt`.
struct InvitationReviewPanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: ReceiveModel
private var state: ReceiveState { model.state }
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge)
if state.isInspecting {
ProgressView().frame(maxWidth: .infinity).padding(40)
}
if let inspection = state.inspection {
let metadata = inspection.metadata
VStack(alignment: .leading, spacing: 8) {
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
Text(L10n.Format.separatedTriple(
first: "\(metadata.fileCount)",
second: String(localized: L10n.Metadata.files).lowercased(),
third: formatBytes(metadata.totalSize)))
.foregroundStyle(colors.foregroundLighter)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
Field(label: String(localized: L10n.Field.receiverName),
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
Text(state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
.font(VniType.bodySmall)
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
if state.isReceiving {
let progressId = state.activeReceiveTransferId
?? model.coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)
} else {
PrimaryButton(
title: String(localized: L10n.Button.receive), action: model.receive,
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
)
}
if let error = state.lastReceiveError {
Text(error.resolved()).font(VniType.bodySmall).foregroundStyle(colors.destructiveDefault)
}
}
}
.padding(.horizontal, 20).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

View File

@@ -0,0 +1,269 @@
import Foundation
import Combine
/// Which acquisition method produced an invitation, ported from `ReceiveMethod`.
enum ReceiveMethod {
case invitationFile
case qrCode
case nfc
}
enum ReceiveHistoryDeleteTarget: Equatable {
case transfer(transferId: UInt64)
case all
}
/// Receive feature state, ported from `feature/receive/ReceiveViewModel.kt`.
struct ReceiveState: Equatable {
var isAcquisitionOpen = false
var ticket = ""
var method: ReceiveMethod?
var inspection: TicketInspectionModel?
var receiverName = ""
var receiveFolder: ReceiveFolder?
var folderAccessStatus: FolderAccessStatus = .unavailable
var isInspecting = false
var isReceiving = false
var activeReceiveTransferId: UInt64?
var lastReceiveError: UiText?
var isWaitingForNfc = false
var historyDeleteTarget: ReceiveHistoryDeleteTarget?
var isDeletingHistory = false
func canReceive(coreInitialized: Bool) -> Bool {
coreInitialized && !ticket.isEmpty && inspection != nil
&& folderAccessStatus == .writable && !isReceiving && !isInspecting
}
static func == (lhs: ReceiveState, rhs: ReceiveState) -> Bool {
lhs.isAcquisitionOpen == rhs.isAcquisitionOpen && lhs.ticket == rhs.ticket
&& lhs.inspection == rhs.inspection && lhs.receiverName == rhs.receiverName
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isInspecting == rhs.isInspecting && lhs.isReceiving == rhs.isReceiving
&& lhs.activeReceiveTransferId == rhs.activeReceiveTransferId
&& lhs.lastReceiveError == rhs.lastReceiveError && lhs.isWaitingForNfc == rhs.isWaitingForNfc
&& lhs.historyDeleteTarget == rhs.historyDeleteTarget && lhs.isDeletingHistory == rhs.isDeletingHistory
}
}
@MainActor
final class ReceiveModel: ObservableObject {
@Published private(set) var state = ReceiveState()
@Published private(set) var coreState = CoreState()
private let repository: CoreGateway
private let fileSystemService: FileSystemService
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.repository = repository
self.fileSystemService = fileSystemService
self.messages = messages
repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
Task {
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
let status = await self.fileSystemService.validateReceiveFolder(folder)
if self.state.receiverName.isEmpty { self.state.receiverName = prefs.username }
self.state.receiveFolder = folder
self.state.folderAccessStatus = status
}
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
if case .transfersChanged(let id) = signal {
Task { _ = await self.repository.refresh() }
if self.state.isReceiving && id != 0 {
self.state.activeReceiveTransferId = id
}
}
}
.store(in: &cancellables)
}
func openAcquisition() { state.isAcquisitionOpen = true }
func dismissAcquisition() {
if !state.isReceiving && !state.isInspecting { resetAcquisition() }
}
func setReceiverName(_ value: String) { state.receiverName = value }
func setWaitingForNfc(_ waiting: Bool) { state.isWaitingForNfc = waiting }
func requestDeleteHistoryItem(_ transferId: UInt64) {
let canDelete = coreState.transfers.contains {
$0.transferId == transferId && $0.direction == .receive && $0.status.isTerminalReceiveHistory
}
if canDelete { state.historyDeleteTarget = .transfer(transferId: transferId) }
}
func requestClearHistory() {
if coreState.transfers.contains(where: { $0.direction == .receive && $0.status.isTerminalReceiveHistory }) {
state.historyDeleteTarget = .all
}
}
func dismissHistoryDelete() {
if !state.isDeletingHistory { state.historyDeleteTarget = nil }
}
func confirmHistoryDelete() {
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
state.isDeletingHistory = true
// Close synchronously: the alert's dismiss binding runs async and no-ops while
// `isDeletingHistory`, which would otherwise leave the target set and macOS
// re-present it.
state.historyDeleteTarget = nil
Task {
let result: Result<Void, Error>
switch target {
case .transfer(let id):
result = await repository.delete(transferId: id)
case .all:
result = (await repository.clearReceiveHistory()).map { _ in () }
}
switch result {
case .success:
state.historyDeleteTarget = nil
state.isDeletingHistory = false
let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
case .failure(let error):
state.isDeletingHistory = false
messages.error(error)
}
}
}
func onInvitationResult(_ method: ReceiveMethod, _ result: Result<String, Error>) {
state.isWaitingForNfc = false
switch result {
case .success(let raw): inspectInvitation(method, raw)
case .failure(let error): messages.error(error)
}
}
func receive() {
let current = state
guard let folder = current.receiveFolder else { return }
if !current.canReceive(coreInitialized: coreState.isInitialized) { return }
state.isReceiving = true
state.lastReceiveError = nil
state.activeReceiveTransferId = nil
Task {
let result: Result<Void, Error>
if folder.kind == .iosSecurityScopedUrl {
result = await repository.receiveIntoSecurityScopedDirectory(
ticket: current.ticket, outputDirectoryUrl: folder.value, receiverName: current.receiverName
)
} else {
result = await repository.receive(
ticket: current.ticket, outputDir: folder.value, receiverName: current.receiverName
)
}
switch result {
case .success:
resetAcquisition()
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(UiMessage(
text: .resource(L10n.Receive.completed),
tone: .success,
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
))
case .failure(let error):
if error.isUserCancellation {
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
return
}
let uiText = error.toUiText()
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = uiText
messages.tryShow(UiMessage(
text: uiText,
tone: .error,
actionLabel: .resource(L10n.Button.retry),
onAction: { self.receive() }
))
}
}
}
func cancelActiveReceive() {
let transferId = state.activeReceiveTransferId
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
guard let transferId else { return }
Task {
let result = await repository.cancel(transferId: transferId)
switch result {
case .success:
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
_ = await repository.refresh()
case .failure(let error):
messages.error(error)
}
}
}
private func revealReceiveFolder(_ folder: ReceiveFolder) {
Task {
let result = await fileSystemService.revealReceiveFolder(folder)
if case .failure = result {
messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error))
}
}
}
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
state.isAcquisitionOpen = true
state.ticket = ticket
state.method = method
state.inspection = nil
state.isInspecting = true
Task {
let result = await repository.inspectTicket(ticket)
switch result {
case .success(let inspection):
state.inspection = inspection
state.isInspecting = false
case .failure(let error):
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
messages.error(error)
}
}
}
private func resetAcquisition() {
state.isAcquisitionOpen = false
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
state.isWaitingForNfc = false
}
}

View File

@@ -0,0 +1,159 @@
import SwiftUI
import SFSafeSymbols
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
struct ReceiveScreen: View {
@ObservedObject var model: ReceiveModel
let windowClass: WindowClass
private var transfers: [Transfer] {
model.coreState.transfers.filter { $0.direction == .receive }
}
private var deletable: [Transfer] {
transfers.filter { $0.status.isTerminalReceiveHistory }
}
var body: some View {
NavigationStack {
Group {
if transfers.isEmpty {
emptyState
} else {
history
}
}
.navigationTitle(Text(String(localized: L10n.Receive.title)))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openAcquisition) {
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
}
}
if !deletable.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestClearHistory) {
Label(String(localized: L10n.Receive.clearHistory), systemSymbol: .trash)
}
}
}
}
}
.adaptiveDrawer(
isPresented: Binding(get: { model.state.isAcquisitionOpen }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.dismissAcquisition
) {
if model.state.ticket.isEmpty {
ReceiveMethodPanel(model: model)
} else {
InvitationReviewPanel(model: model)
}
}
.alert(
Text(String(localized: clearAllPending ? L10n.Receive.clearHistoryTitle : L10n.Receive.deleteHistoryTitle)),
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
) {
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete)
Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer),
role: .destructive, action: model.confirmHistoryDelete)
} message: {
historyDeleteMessage
}
}
private var history: some View {
List {
Section {
ForEach(transfers) { transfer in
ReceiveTransferRow(
transfer: transfer,
progress: progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if transfer.status.isTerminalReceiveHistory {
Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId)
} label: {
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
}
}
}
.contextMenu {
if transfer.status.isTerminalReceiveHistory {
Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId)
} label: {
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
}
}
}
}
} header: {
Text(String(localized: L10n.Receive.historyTitle))
} footer: {
Text(String(localized: L10n.Receive.newSubtitle))
}
}
}
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown)
} description: {
Text(String(localized: L10n.Receive.emptyBody))
} actions: {
Button(action: model.openAcquisition) {
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
private var clearAllPending: Bool { model.state.historyDeleteTarget == .all }
@ViewBuilder
private var historyDeleteMessage: some View {
if let target = model.state.historyDeleteTarget {
if target == .all {
Text(String(localized: L10n.Receive.clearHistoryDescription))
} else {
Text(L10n.Receive.deleteHistoryDescription(
transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
}
}
}
private func transferName(for target: ReceiveHistoryDeleteTarget) -> String? {
if case .transfer(let id) = target {
return transfers.first { $0.transferId == id }?.transferName
}
return nil
}
}
private struct ReceiveTransferRow: View {
let transfer: Transfer
let progress: TransferProgress?
var body: some View {
HStack(spacing: 12) {
Image(systemSymbol: .doc)
.foregroundStyle(.secondary)
.frame(width: 40, height: 40)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) {
Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer))
.font(.body).lineLimit(1)
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: statusLabel(transfer.status)))
.font(.caption).foregroundStyle(.secondary)
if transfer.status == .receiving, let progress {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
.padding(.top, 2)
}
}
Spacer(minLength: 0)
}
}
}

View File

@@ -0,0 +1,141 @@
import Foundation
import Combine
/// Native Apple preview cache and thumbnail loader.
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
struct PreviewStoragePolicy {
var maxEntryBytes: Int = 512 * 1024
var maxTotalBytes: Int64 = 20 * 1024 * 1024
}
private struct PreviewFileInfo {
let transferId: UInt64
let byteSize: Int64
let modifiedAtMillis: Int64
}
@MainActor
final class FilePreviewRepository: ObservableObject {
@Published private(set) var previews: [UInt64: Data] = [:]
private let directory: String
private let policy: PreviewStoragePolicy
private let fm = FileManager.default
init(appDataDir: String, policy: PreviewStoragePolicy = PreviewStoragePolicy()) {
self.directory = (appDataDir as NSString).appendingPathComponent("ui/previews")
self.policy = policy
}
func restore(activeTransferIds: Set<UInt64>) {
let files = listFiles()
for file in files where !(activeTransferIds.contains(file.transferId)
&& (1...Int64(policy.maxEntryBytes)).contains(file.byteSize)) {
deleteFile(file.transferId)
}
enforceQuota()
var result: [UInt64: Data] = [:]
for file in listFiles() where activeTransferIds.contains(file.transferId) {
if let data = readFile(file.transferId), data.isSupportedPreview, data.count <= policy.maxEntryBytes {
result[file.transferId] = data
} else {
deleteFile(file.transferId)
}
}
previews = result
}
func save(transferId: UInt64, bytes: Data) {
guard (1...policy.maxEntryBytes).contains(bytes.count), bytes.isSupportedPreview else { return }
guard writeAtomically(transferId: transferId, bytes: bytes) else { return }
enforceQuota(protectedTransferId: transferId)
if readFile(transferId) != nil {
previews[transferId] = bytes
}
}
func remove(transferId: UInt64) {
deleteFile(transferId)
previews.removeValue(forKey: transferId)
}
// MARK: - Store (ported from IosPreviewStore)
private func enforceQuota(protectedTransferId: UInt64? = nil) {
let files = listFiles().sorted { $0.modifiedAtMillis < $1.modifiedAtMillis }
var total = files.reduce(Int64(0)) { $0 + $1.byteSize }
for file in files {
if total <= policy.maxTotalBytes { break }
if file.transferId == protectedTransferId { continue }
deleteFile(file.transferId)
total -= file.byteSize
previews.removeValue(forKey: file.transferId)
}
}
private func ensureDirectory() {
try? fm.createDirectory(atPath: directory, withIntermediateDirectories: true)
}
private func path(_ transferId: UInt64) -> String {
(directory as NSString).appendingPathComponent("\(transferId).preview")
}
private func listFiles() -> [PreviewFileInfo] {
ensureDirectory()
guard let names = try? fm.contentsOfDirectory(atPath: directory) else { return [] }
return names.compactMap { name in
guard name.hasSuffix(".preview"),
let id = UInt64(name.replacingOccurrences(of: ".preview", with: "")) else { return nil }
let full = (directory as NSString).appendingPathComponent(name)
guard let attrs = try? fm.attributesOfItem(atPath: full) else { return nil }
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
let modified = ((attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0) * 1000
return PreviewFileInfo(transferId: id, byteSize: size, modifiedAtMillis: Int64(modified))
}
}
private func readFile(_ transferId: UInt64) -> Data? {
try? Data(contentsOf: URL(fileURLWithPath: path(transferId)))
}
private func writeAtomically(transferId: UInt64, bytes: Data) -> Bool {
ensureDirectory()
if fm.fileExists(atPath: path(transferId)) { return true }
let temporary = (directory as NSString).appendingPathComponent(".\(transferId).tmp")
do {
try bytes.write(to: URL(fileURLWithPath: temporary), options: .atomic)
} catch {
return false
}
do {
if fm.fileExists(atPath: path(transferId)) {
try? fm.removeItem(atPath: temporary)
return true
}
try fm.moveItem(atPath: temporary, toPath: path(transferId))
return true
} catch {
try? fm.removeItem(atPath: temporary)
return false
}
}
private func deleteFile(_ transferId: UInt64) {
try? fm.removeItem(atPath: path(transferId))
}
}
extension Data {
/// Matches `isSupportedPreview()`: PNG / JPEG / WEBP magic bytes.
var isSupportedPreview: Bool {
let bytes = [UInt8](self)
let png = bytes.count >= 8 && bytes[0] == 0x89
&& bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 // "PNG"
let jpeg = bytes.count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
let webp = bytes.count >= 12
&& bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 // "RIFF"
&& bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 // "WEBP"
return png || jpeg || webp
}
}

View File

@@ -0,0 +1,386 @@
import Foundation
import Combine
enum TransferDetailPanel: Equatable {
case activity
case receivers
case share
}
/// Which invitation delivery action produced a result (for success copy).
enum InvitationAction {
case export
case share
case nfc
}
/// Send feature state, ported from `feature/send/SendViewModel.kt` (`SendState`).
struct SendState: Equatable {
var isComposerOpen = false
var selectedFiles: [PickedShareFile] = []
var transferName = ""
var senderName = ""
var accessPolicy: ShareAccessPolicy = .requireApproval
var isSharing = false
var selectedTransferId: UInt64?
var transferThumbnails: [UInt64: Data] = [:]
var detailPanel: TransferDetailPanel?
var receiverHistory: [ReceiverRequestModel] = []
var isLoadingReceivers = false
var isDeleteConfirmationOpen = false
var isDeleting = false
func canCreateShare(coreInitialized: Bool) -> Bool {
coreInitialized && !selectedFiles.isEmpty && !transferName.isEmpty && !isSharing
}
}
@MainActor
final class SendModel: ObservableObject {
@Published private(set) var state = SendState()
@Published private(set) var coreState = CoreState()
/// Requests a file/folder pick or clipboard copy, consumed by the view layer.
@Published var pendingFilePick = false
@Published var pendingFolderPick = false
/// Receiver delivery records per active (sharing) transfer, used to decide
/// which transfers still have an in-flight receiver. Delivery status is the
/// authoritative signal; byte-transfer events alone don't reliably mark a
/// small transfer complete.
@Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:]
private let repository: CoreGateway
private let fileSystemService: FileSystemService
private let filePreviewRepository: FilePreviewRepository
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
filePreviewRepository: FilePreviewRepository,
messages: UiMessageController
) {
self.repository = repository
self.fileSystemService = fileSystemService
self.filePreviewRepository = filePreviewRepository
self.messages = messages
repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .transfersChanged:
Task { _ = await self.repository.refresh() }
case .receiverHistoryChanged(let id), .approvalChanged(let id):
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
self.refreshReceiverStatuses(for: id)
}
}
.store(in: &cancellables)
// Keep receiver delivery records current for every sharing/importing
// outgoing transfer (new shares appear here; status transitions arrive via
// the receiverHistoryChanged signal above).
repository.statePublisher
.map { core -> Set<UInt64> in
Set(core.transfers
.filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) }
.map(\.transferId))
}
.removeDuplicates()
.sink { [weak self] ids in self?.syncSharingReceivers(ids) }
.store(in: &cancellables)
filePreviewRepository.$previews
.sink { [weak self] previews in self?.state.transferThumbnails = previews }
.store(in: &cancellables)
repository.statePublisher
.map { core -> Set<UInt64>? in
core.isInitialized ? Set(core.transfers.map(\.transferId)) : nil
}
.removeDuplicates()
.sink { [weak self] ids in
if let ids { self?.filePreviewRepository.restore(activeTransferIds: ids) }
}
.store(in: &cancellables)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
if self.state.senderName.isEmpty { self.state.senderName = prefs.username }
}
.store(in: &cancellables)
}
// MARK: - Composer
func openComposer() {
if state.isSharing { return }
let discarded = state.selectedFiles
state.isComposerOpen = true
state.selectedFiles = []
state.transferName = ""
state.accessPolicy = .requireApproval
discardPickedFiles(discarded)
}
func dismissComposer() {
if state.isSharing { return }
let discarded = state.selectedFiles
state.isComposerOpen = false
state.selectedFiles = []
state.transferName = ""
state.accessPolicy = .requireApproval
discardPickedFiles(discarded)
}
func selectFile() { pendingFilePick = true }
func selectFolder() { pendingFolderPick = true }
func onFilesPicked(_ files: [PickedShareFile]) {
if files.isEmpty { return }
let selectedValues = Set(files.map(\.value))
let discarded = state.selectedFiles.filter { !selectedValues.contains($0.value) }
state.isComposerOpen = true
state.selectedFiles = files
state.transferName = defaultTransferName(files)
discardPickedFiles(discarded)
}
func onFilePickFailed(_ reason: String) {
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
}
func clearSelectedSource() {
let discarded = state.selectedFiles
state.selectedFiles = []
state.transferName = ""
discardPickedFiles(discarded)
}
func removeSelectedFile(_ value: String) {
let discarded = state.selectedFiles.filter { $0.value == value }
let remaining = state.selectedFiles.filter { $0.value != value }
let wasDefault = state.transferName == defaultTransferName(state.selectedFiles)
state.selectedFiles = remaining
state.transferName = remaining.isEmpty ? "" : (wasDefault ? defaultTransferName(remaining) : state.transferName)
discardPickedFiles(discarded)
}
func setTransferName(_ value: String) { state.transferName = value }
func setSenderName(_ value: String) { state.senderName = value }
func setAccessPolicy(_ value: ShareAccessPolicy) { state.accessPolicy = value }
// MARK: - Transfer detail
func openTransfer(_ transferId: UInt64) {
state.selectedTransferId = transferId
state.detailPanel = nil
refreshReceivers(transferId)
}
func closeTransferDetails() {
state.selectedTransferId = nil
state.detailPanel = nil
state.receiverHistory = []
state.isDeleteConfirmationOpen = false
}
func openActivity() { state.detailPanel = .activity }
func openShare() { state.detailPanel = .share }
func openReceivers() {
guard let id = state.selectedTransferId else { return }
state.detailPanel = .receivers
refreshReceivers(id)
}
func closeDetailPanel() { state.detailPanel = nil }
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
func confirmDeleteTransfer() {
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
state.isDeleting = true
// Close synchronously: the alert's dismiss binding runs async and no-ops while
// `isDeleting`, which would otherwise leave the flag true and macOS re-present it.
state.isDeleteConfirmationOpen = false
Task {
let result = await repository.delete(transferId: transferId)
switch result {
case .success:
filePreviewRepository.remove(transferId: transferId)
state.selectedTransferId = nil
state.detailPanel = nil
state.receiverHistory = []
state.isDeleteConfirmationOpen = false
state.isDeleting = false
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
case .failure(let error):
state.isDeleting = false
messages.error(error)
}
}
}
/// Deletes a transfer by id, independent of the detail selection used by the
/// list context menu so it can act inline without navigating into the detail.
func deleteTransfer(id: UInt64) {
if state.isDeleting { return }
state.isDeleting = true
Task {
let result = await repository.delete(transferId: id)
switch result {
case .success:
filePreviewRepository.remove(transferId: id)
if state.selectedTransferId == id {
state.selectedTransferId = nil
state.detailPanel = nil
state.receiverHistory = []
}
state.isDeleting = false
_ = await repository.refresh()
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
case .failure(let error):
state.isDeleting = false
messages.error(error)
}
}
}
/// Cancels/refuses a single receiver by responding to its request negatively.
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
/// receivers that are still pending or accepted.
func cancelReceiver(requestId: String) {
Task {
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
switch result {
case .success:
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
_ = await repository.refresh()
case .failure(let error):
messages.error(error)
}
}
}
/// Stops an active outgoing share (interrupts any in-flight receivers). The
/// transfer stays in history as "Stopped". Uses the core's `cancelTransfer`.
func stopSharing(transferId: UInt64) {
Task {
let result = await repository.cancel(transferId: transferId)
switch result {
case .success:
_ = await repository.refresh()
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info))
case .failure(let error):
messages.error(error)
}
}
}
// MARK: - Invitation results / share
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
switch result {
case .success:
let key: String.LocalizationValue?
switch action {
case .export: key = L10n.Transfer.invitationSaved
case .nfc: key = L10n.Transfer.nfcWritten
case .share: key = nil // system share sheet already confirms
}
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
case .failure(let error):
messages.error(error)
}
}
func createShare() {
let current = state
if current.selectedFiles.isEmpty { return }
if !current.canCreateShare(coreInitialized: coreState.isInitialized) { return }
state.isSharing = true
Task {
let result = await fileSystemService.sharePickedFiles(
repository: repository,
files: current.selectedFiles,
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
accessPolicy: current.accessPolicy
)
switch result {
case .success(let share):
await fileSystemService.discardPickedFiles(current.selectedFiles)
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
filePreviewRepository.save(transferId: share.transferId, bytes: thumb)
}
state.isComposerOpen = false
state.selectedFiles = []
state.transferName = ""
state.accessPolicy = .requireApproval
state.isSharing = false
// Jump straight to the new transfer's share panel (QR + delivery) rather
// than dropping the user on the list to drill in manually. Refresh first
// so the transfer exists in state before it's selected.
_ = await repository.refresh()
state.selectedTransferId = share.transferId
state.detailPanel = .share
refreshReceivers(share.transferId)
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
case .failure(let error):
state.isSharing = false
messages.error(error)
}
}
}
// MARK: - Internals
private func defaultTransferName(_ files: [PickedShareFile]) -> String {
if files.isEmpty { return "" }
if files.count == 1 { return files[0].displayName }
if files.allSatisfy(\.isDirectory) { return "\(files.count) folders" }
return "\(files.count) files"
}
private func discardPickedFiles(_ files: [PickedShareFile]) {
if files.isEmpty { return }
Task { await fileSystemService.discardPickedFiles(files) }
}
/// Refresh the receiver records for the sharing set, pruning transfers that are
/// no longer active.
private func syncSharingReceivers(_ ids: Set<UInt64>) {
receiversByTransfer = receiversByTransfer.filter { ids.contains($0.key) }
for id in ids { refreshReceiverStatuses(for: id) }
}
private func refreshReceiverStatuses(for transferId: UInt64) {
Task {
if case .success(let requests) = await repository.receiverRequests(transferId: transferId) {
receiversByTransfer[transferId] = requests
}
}
}
private func refreshReceivers(_ transferId: UInt64) {
state.isLoadingReceivers = true
Task {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
state.receiverHistory = requests
state.isLoadingReceivers = false
case .failure(let error):
state.isLoadingReceivers = false
messages.error(error)
}
}
}
}

View File

@@ -0,0 +1,267 @@
import SwiftUI
import SFSafeSymbols
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
/// with the composer and detail panels as native sheets and delete as an alert.
struct SendScreen: View {
@ObservedObject var model: SendModel
let windowClass: WindowClass
/// Transfer whose share panel is presented inline from the list context menu.
@State private var shareTarget: Transfer?
/// Transfer pending an inline (list-level) delete confirmation.
@State private var deleteTarget: Transfer?
private var outgoing: [Transfer] {
model.coreState.transfers.filter { $0.direction == .send }
}
private var selectedTransfer: Transfer? {
guard let id = model.state.selectedTransferId else { return nil }
return outgoing.first { $0.transferId == id }
}
private var detailsBinding: Binding<Bool> {
Binding(get: { model.state.selectedTransferId != nil }, set: { if !$0 { model.closeTransferDetails() } })
}
var body: some View {
NavigationStack {
Group {
if outgoing.isEmpty {
emptyState
} else {
catalog
}
}
.navigationTitle(Text(String(localized: L10n.Send.title)))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openComposer) {
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
}
}
}
.navigationDestination(isPresented: detailsBinding) {
if let transfer = selectedTransfer {
detailView(for: transfer)
}
}
// Attached inside the NavigationStack (a different sheet host than the
// composer drawer on the outer body, so the two don't clash). Opens the
// share panel over the list without navigating into the transfer detail.
.adaptiveDrawer(
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
windowClass: windowClass,
onDismiss: { shareTarget = nil }
) {
if let shareTarget {
TransferSharePanel(model: model, transfer: shareTarget)
}
}
}
.adaptiveDrawer(
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.dismissComposer
) {
TransferComposer(model: model, windowClass: windowClass)
}
.alert(
Text(String(localized: L10n.Transfer.deleteTitle)),
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } })
) {
Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil }
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) {
if let target = deleteTarget { model.deleteTransfer(id: target.transferId) }
deleteTarget = nil
}
} message: {
if let target = deleteTarget {
Text(L10n.Transfer.deleteDescription(
transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle)))
}
}
}
/// The pushed transfer details view, with its detail-panel sheet and delete
/// alert attached here so they present from the detail's own context (presenting
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
private func detailView(for transfer: Transfer) -> some View {
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.closeDetailPanel
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel)
}
}
.alert(
Text(String(localized: L10n.Transfer.deleteTitle)),
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
) {
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
} message: {
Text(L10n.Transfer.deleteDescription(
transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
}
}
private var catalog: some View {
List {
Section {
ForEach(outgoing) { transfer in
Button {
model.openTransfer(transfer.transferId)
} label: {
TransferListItem(
transfer: transfer,
thumbnail: model.state.transferThumbnails[transfer.transferId],
progress: progress(for: transfer)
)
}
.buttonStyle(.plain)
.contextMenu {
if transfer.ticket != nil {
Button {
shareTarget = transfer
} label: {
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
}
}
if transfer.status == .sharing {
Button(role: .destructive) {
model.stopSharing(transferId: transfer.transferId)
} label: {
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
}
}
Divider()
Button(role: .destructive) {
deleteTarget = transfer
} label: {
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
}
}
}
} header: {
Text(String(localized: L10n.Send.transfersTitle))
} footer: {
Text(String(localized: L10n.Send.subtitle))
}
}
}
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
} description: {
Text(String(localized: L10n.Send.emptyBody))
} actions: {
Button(action: model.openComposer) {
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
private func progress(for transfer: Transfer) -> TransferProgress? {
switch transfer.status {
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
case .sharing: return sharingProgress(for: transfer)
default: return nil
}
}
/// Progress for an active share, driven by receivers whose delivery is still
/// in flight (`.accepted`). Returns nil when none are downloading, so the bar
/// clears once every receiver has completed even if byte events lag.
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
if active.isEmpty { return nil }
let fractions = active.compactMap {
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
}
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
if active.count == 1 {
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
labelKey: L10n.Progress.sending, progress: combined)
}
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
labelKey: L10n.Progress.sending, progress: combined,
label: L10n.Progress.sendingToCount(count: active.count))
}
}
private struct TransferListItem: View {
let transfer: Transfer
let thumbnail: Data?
let progress: TransferProgress?
var body: some View {
HStack(spacing: 12) {
FileArtwork(thumbnail: thumbnail)
.frame(width: 40, height: 40)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
.font(.body).lineLimit(1)
Spacer()
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
}
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: accessPolicyLabel(transfer.accessPolicy)))
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress, transfer.status == .importing || transfer.status == .sharing {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
.padding(.top, 2)
}
}
Image(systemSymbol: .chevronForward)
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
}
struct FileArtwork: View {
let thumbnail: Data?
var body: some View {
if let thumbnail, let image = PlatformImage.from(data: thumbnail) {
image.resizable().aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Image(systemSymbol: .doc)
.font(.system(size: 18))
.foregroundStyle(.secondary)
}
}
}
func statusLabel(_ status: TransferStatus) -> String {
String(localized: statusLabelKey(status))
}
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
switch policy {
case .requireApproval: return String(localized: L10n.Send.accessApproval)
case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
}
}
extension TransferStatus {
var pillTone: PillTone {
switch self {
case .sharing, .done: return .brand
case .importing, .receiving: return .warning
case .failed, .cancelled: return .destructive
case .stopped: return .neutral
}
}
}

View File

@@ -0,0 +1,182 @@
import SwiftUI
import SFSafeSymbols
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
/// Two steps: choose files/folder, then review + name + access policy + share.
struct TransferComposer: View {
@ObservedObject var model: SendModel
let windowClass: WindowClass
private var state: SendState { model.state }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if state.selectedFiles.isEmpty {
chooseStep
} else {
reviewStep
}
}
.padding(.horizontal, 20).padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.sendPickers(model: model)
}
private var chooseStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold)
Text(String(localized: L10n.Send.chooseFileBody))
.font(.subheadline).foregroundStyle(.secondary)
VStack(spacing: 14) {
Image(systemSymbol: .doc).font(.system(size: 30)).foregroundStyle(.tint)
PrimaryButton(title: String(localized: L10n.Button.chooseFiles), action: model.selectFile).fixedSize()
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder)
}
.frame(maxWidth: .infinity)
.padding(28)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
}
}
private var reviewStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: L10n.Send.reviewTitle)).font(.title2).fontWeight(.semibold)
if state.selectedFiles.count > 1 {
Text(L10n.Send.selectedFilesCount(count: state.selectedFiles.count))
.font(.subheadline).foregroundStyle(.secondary)
}
ForEach(state.selectedFiles) { file in
SelectedFileCard(
file: file,
canRemove: state.selectedFiles.count > 1 && !state.isSharing,
onRemove: { model.removeSelectedFile(file.value) }
)
}
Field(label: String(localized: L10n.Field.transferName),
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
Field(label: String(localized: L10n.Field.senderName),
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
Text(String(localized: L10n.Send.accessTitle)).font(.headline)
PolicyOption(
icon: .checkmarkShield, titleKey: L10n.Send.accessApproval, descKey: L10n.Send.accessApprovalDescription,
selected: state.accessPolicy == .requireApproval,
onTap: { model.setAccessPolicy(.requireApproval) }
)
PolicyOption(
icon: .globe, titleKey: L10n.Send.accessAnyone, descKey: L10n.Send.accessAnyoneDescription,
selected: state.accessPolicy == .anyoneWithTransfer,
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
)
if state.accessPolicy == .anyoneWithTransfer {
Label(String(localized: L10n.Send.accessAnyoneWarning), systemSymbol: .exclamationmarkTriangleFill)
.font(.caption).foregroundStyle(.orange)
}
actions
}
}
private var actions: some View {
let shareTitle = state.isSharing
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
return VStack(spacing: 10) {
PrimaryButton(
title: shareTitle, action: model.createShare,
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
)
// Secondary source actions as an even row of bordered buttons rather than
// bare text links, so they read as controls and align with the primary.
HStack(spacing: 10) {
sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
if windowClass != .phone {
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
}
}
}
}
private func sourceButton(
title: String.LocalizationValue, symbol: SFSymbol, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(String(localized: title), systemSymbol: symbol)
.lineLimit(1)
.minimumScaleFactor(0.85)
.frame(maxWidth: .infinity)
.frame(minHeight: 20)
}
.buttonStyle(.bordered)
.controlSize(.large)
.tint(.secondary)
.disabled(state.isSharing)
}
}
private struct SelectedFileCard: View {
let file: PickedShareFile
let canRemove: Bool
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
FileArtwork(thumbnail: file.thumbnailData)
.frame(width: 44, height: 44)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 3) {
Text(file.displayName).lineLimit(1)
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if canRemove {
Button(role: .destructive, action: onRemove) {
Image(systemSymbol: .trash)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.padding(14)
.frame(maxWidth: .infinity)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14))
}
private var subtitle: String {
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
if let size = file.sizeBytes { return formatBytes(size) }
return String(localized: L10n.Send.fileSizeUnknown)
}
}
private struct PolicyOption: View {
let icon: SFSymbol
let titleKey: String.LocalizationValue
let descKey: String.LocalizationValue
let selected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemSymbol: icon)
.font(.system(size: 20))
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: 22)
VStack(alignment: .leading, spacing: 3) {
Text(String(localized: titleKey))
Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemSymbol: selected ? .checkmarkCircleFill : .circle)
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
}
.padding(14)
.frame(maxWidth: .infinity)
.background(.quaternary.opacity(selected ? 0.8 : 0.4), in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,417 @@
import SwiftUI
import SFSafeSymbols
import CoreImage.CIFilterBuiltins
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
struct TransferDetailsView: View {
@ObservedObject var model: SendModel
let transfer: Transfer
let events: [CoreEventModel]
@State private var showStopConfirmation = false
private var isActiveShare: Bool {
transfer.status == .sharing || transfer.status == .importing
}
private var pendingReceivers: Int {
model.state.receiverHistory.filter { $0.status == .requested || $0.status == .accepted }.count
}
private var completedReceivers: Int {
model.state.receiverHistory.filter { $0.status == .completed }.count
}
var body: some View {
Form {
Section {
LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status))
LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize))
LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy))
} header: {
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
}
Section {
DetailDestination(
title: String(localized: L10n.Transfer.activityTitle),
description: String(localized: L10n.Transfer.activityDescription),
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
onTap: model.openActivity
)
DetailDestination(
title: String(localized: L10n.Transfer.receiversTitle),
description: receiversDescription(pendingReceivers, completedReceivers),
count: pendingReceivers + completedReceivers,
onTap: model.openReceivers
)
}
Section {
if isActiveShare {
Button(role: .destructive) {
showStopConfirmation = true
} label: {
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
}
}
Button(role: .destructive, action: model.requestDeleteTransfer) {
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openShare) {
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
}
.help(String(localized: L10n.Transfer.shareTitle))
}
}
.confirmationDialog(
Text(String(localized: L10n.Send.stopSharing)),
isPresented: $showStopConfirmation,
titleVisibility: .visible
) {
Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
model.stopSharing(transferId: transfer.transferId)
}
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
} message: {
Text(String(localized: L10n.Send.stopSharingDescription))
}
}
}
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
if pending > 0 && completed > 0 {
return L10n.Format.separatedPair(
first: L10n.Transfer.receiversPending(count: pending),
second: L10n.Transfer.receiversCompletedCount(count: completed))
}
if pending > 0 { return L10n.Transfer.receiversPending(count: pending) }
if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) }
return String(localized: L10n.Transfer.receiversDescription)
}
private struct DetailDestination: View {
let title: String
let description: String
let count: Int
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(title).foregroundStyle(.primary)
Text(description).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if count > 0 {
Text(verbatim: "\(count)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Image(systemSymbol: .chevronForward)
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Detail panels
struct DetailPanelContent: View {
@ObservedObject var model: SendModel
let transfer: Transfer
let panel: TransferDetailPanel
var body: some View {
switch panel {
case .activity:
TransferActivityPanel(events: model.coreState.events, transferId: transfer.transferId)
case .receivers:
ReceiverHistoryPanel(
receivers: model.state.receiverHistory,
loading: model.state.isLoadingReceivers,
events: model.coreState.events,
transferTotalSize: transfer.totalSize,
onCancel: model.cancelReceiver
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
}
}
}
private struct PanelContainer<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(title).font(VniType.titleLarge)
content()
}
.padding(.horizontal, 20).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct TransferActivityPanel: View {
@Environment(\.vniColors) private var colors
let events: [CoreEventModel]
let transferId: UInt64
var body: some View {
let visible = events
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
.sorted { $0.timestamp > $1.timestamp }
PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
if visible.isEmpty {
Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter)
} else {
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
if index > 0 { Divider().overlay(colors.borderDefault) }
Text(String(localized: event.activityTitleKey))
.fontWeight(.medium).padding(.vertical, 14)
}
}
}
}
}
struct ReceiverHistoryPanel: View {
@Environment(\.vniColors) private var colors
let receivers: [ReceiverRequestModel]
let loading: Bool
let events: [CoreEventModel]
let transferTotalSize: UInt64
let onCancel: (String) -> Void
var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
if loading {
ProgressView().frame(maxWidth: .infinity).padding(40)
} else if receivers.isEmpty {
Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter)
} else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
if index > 0 { Divider().overlay(colors.borderDefault) }
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
}
}
}
}
private func sendProgress(for receiver: ReceiverRequestModel) -> TransferProgress? {
switch receiver.status {
case .accepted, .requested:
return progressForReceiver(events: events, transferId: receiver.transferId,
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transferTotalSize)
default: return nil
}
}
}
private struct ReceiverRow: View {
@Environment(\.vniColors) private var colors
let receiver: ReceiverRequestModel
let sendProgress: TransferProgress?
let onCancel: (String) -> Void
/// Only pending requests can be cancelled per-receiver: the core rejects a
/// negative response to an already-accepted request ("...not approved, or it
/// was refused"). Interrupting an in-flight receiver needs Stop sharing.
private var isCancelable: Bool {
receiver.status == .requested
}
var body: some View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired
&& receiver.status != .failed
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1)
if let deviceName = receiver.receiverDeviceName, deviceName != name {
Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
if showLive, let sendProgress {
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
} else {
Text(String(localized: receiver.status.statusTextKey))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(receiver.status.statusColor(colors))
}
if let reason = receiver.reason, !reason.isEmpty {
Text(receiverReasonUiText(reason).resolved())
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if isCancelable {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 13)
}
}
struct TransferSharePanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
let transfer: Transfer
var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
switch transfer.invitationPresentation {
case .ready(let ticket):
let qrImage = QRCode.generate(from: ticket)
qrCard(image: qrImage)
if qrImage != nil {
Text(String(localized: L10n.Transfer.scanQr))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
}
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
case .preparing:
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable:
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
.foregroundStyle(colors.foregroundLighter)
}
}
}
private func qrCard(image: Image?) -> some View {
ZStack {
if let image {
image.interpolation(.none).resizable().scaledToFit().padding(14)
} else {
VStack(spacing: 10) {
Image(systemSymbol: .qrcode)
.font(.system(size: 36, weight: .medium))
Text(String(localized: L10n.Transfer.qrUnavailable))
.font(VniType.bodySmall)
.multilineTextAlignment(.center)
}
.foregroundStyle(.black.opacity(0.72))
.padding(22)
}
}
.frame(width: 268, height: 268)
.background(Color.white, in: RoundedRectangle(cornerRadius: 18))
.frame(maxWidth: .infinity)
}
}
enum TransferInvitationPresentation: Equatable {
case preparing
case ready(String)
case unavailable
}
extension Transfer {
var invitationPresentation: TransferInvitationPresentation {
switch status {
case .importing:
return .preparing
case .sharing:
guard let ticket, !ticket.isEmpty else { return .preparing }
return .ready(ticket)
case .receiving, .done, .failed, .cancelled, .stopped:
return .unavailable
}
}
}
// MARK: - QR generation (CoreImage)
enum QRCode {
static func generate(from string: String) -> Image? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
#if os(iOS)
return Image(uiImage: UIImage(cgImage: cgImage))
#else
return Image(nsImage: NSImage(cgImage: cgImage, size: .zero))
#endif
}
}
// MARK: - Event helpers (ported from TransferDetails.kt)
extension CoreEventModel {
var isMeaningfulActivity: Bool {
(phase == "import" && kind == "started")
|| (phase == "ticket" && kind == "created")
|| (phase == "network" && (kind == "connecting" || kind == "connected"))
|| (phase == "download" && kind == "found-collection")
|| (phase == "lifecycle" && ["done", "cancelled", "share-stopped"].contains(kind))
|| ["receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
}
var activityTitleKey: String.LocalizationValue {
if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing }
if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady }
if phase == "network" { return L10n.Transfer.eventConnecting }
if phase == "download" { return L10n.Transfer.eventDownloading }
if phase == "export" { return L10n.Transfer.eventSaving }
if kind == "receiver-requested" { return L10n.Transfer.eventRequested }
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved }
if kind == "receiver-refused" { return L10n.Transfer.eventRefused }
if kind == "receiver-completed" { return L10n.Transfer.eventCompleted }
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped }
if kind == "failed" { return L10n.Transfer.eventFailed }
return L10n.Transfer.eventUpdated
}
}
extension ReceiverDeliveryStatus {
var statusTextKey: String.LocalizationValue {
switch self {
case .requested: return L10n.Transfer.receiverRequested
case .accepted: return L10n.Transfer.receiverAccepted
case .refused: return L10n.Transfer.receiverRefused
case .expired: return L10n.Transfer.receiverExpired
case .completed: return L10n.Transfer.receiverCompleted
case .failed: return L10n.Transfer.receiverFailed
case .unknown: return L10n.Transfer.receiverUnknown
}
}
func statusColor(_ colors: VniDropColors) -> Color {
switch self {
case .completed: return colors.brandDefault
case .refused, .expired, .failed: return colors.destructiveDefault
default: return colors.foregroundLighter
}
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,61 @@
import SwiftUI
enum NfcShareAvailability { case available, unavailable, hidden }
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
/// Platform implementations perform export, native share, and NFC write.
@MainActor
protocol TransferShareActions: AnyObject {
var canUseNativeShare: Bool { get }
var nfcAvailability: NfcShareAvailability { get }
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void)
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void)
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void)
func cancelNfcWrite()
}
/// The QR + delivery buttons for a transfer's share panel, ported from the button
/// stack in `TransferSharePanel` (`TransferDetails.kt`).
struct ShareActionsView: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
let transfer: Transfer
let ticket: String
@State private var actions: TransferShareActions = makePlatformShareActions()
@State private var writingNfc = false
var body: some View {
VStack(spacing: 12) {
if actions.nfcAvailability != .hidden {
SecondaryButton(
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
action: {
writingNfc = true
actions.writeInvitationToNfc(ticket: ticket) { result in
writingNfc = false
model.onInvitationResult(.nfc, result)
}
},
enabled: actions.nfcAvailability == .available && !writingNfc
)
if actions.nfcAvailability == .unavailable {
Text(String(localized: L10n.Transfer.nfcUnavailable))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
}
SecondaryButton(title: String(localized: L10n.Button.downloadInvitation), action: {
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.export, $0)
}
})
PrimaryButton(title: String(localized: L10n.Button.nativeShare), action: {
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.share, $0)
}
}, enabled: actions.canUseNativeShare)
}
.onDisappear { actions.cancelNfcWrite() }
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
/// Bug-report draft, ported from `diagnostics/BugReportService.kt`.
struct BugReportDraft {
let whatHappened: String
let expected: String
let steps: String
let contact: String
let includeLogs: Bool
}
/// Bug-report submission. The full diagnostics transport (URLSession + build
/// config) lands in the diagnostics phase; this protocol is the stable seam.
@MainActor
protocol BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error>
func previewLogBytes() async -> Int
}
/// Offline-safe no-op used until the diagnostics transport is configured.
struct NoopBugReportService: BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
.failure(InvitationError.bugReportingUnavailable)
}
func previewLogBytes() async -> Int { 0 }
}
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
enum DiagnosticsBuildConfig {
static let included = false
}

View File

@@ -0,0 +1,606 @@
import Foundation
import Combine
/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`.
enum SettingsSection: Hashable {
case overview
case preferences
case appearance
case notifications
case network
case storage
case about
case bugReport
var titleKey: String.LocalizationValue {
switch self {
case .overview: return L10n.Settings.title
case .preferences: return L10n.Preferences.title
case .appearance: return L10n.Appearance.title
case .notifications: return L10n.Notifications.title
case .network: return L10n.Settings.networkTitle
case .storage: return L10n.Storage.title
case .about: return L10n.About.title
case .bugReport: return L10n.About.bugReport
}
}
}
/// On-disk usage breakdown for the Storage screen.
struct StorageBreakdown: Equatable {
var receivedFiles: UInt64 = 0
var transferCache: UInt64 = 0
var appData: UInt64 = 0
var temporary: UInt64 = 0
var total: UInt64 { receivedFiles + transferCache + appData + temporary }
}
struct SettingsState: Equatable {
var selectedSection: SettingsSection = .overview
var username = ""
var receiveFolder: ReceiveFolder?
var folderAccessStatus: FolderAccessStatus = .unavailable
var isValidatingFolder = false
var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var relayMode: RelayPreferenceMode = .automatic
var relayURLs: [String] = []
var relayValidationError: RelayConfigurationValidationError?
var relayConfigurationIsDirty = false
var isApplyingRelayConfiguration = false
var hasActiveNetworkWork = false
var endpointId: String?
var relayApplyErrorKey: String.LocalizationValue?
var deviceInfo: DeviceInfo?
var appVersion = ""
var isLoadingDeviceInfo = false
var bugWhatHappened = ""
var bugExpected = ""
var bugSteps = ""
var bugContact = ""
var bugIncludeLogs = true
var isSubmittingBugReport = false
var bugLogPreviewBytes = 0
var storage: StorageBreakdown?
var isCalculatingStorage = false
var storageLoadFailed = false
var isDeletingTransfers = false
var isCleaningStorage = false
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isValidatingFolder == rhs.isValidatingFolder
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
&& lhs.relayValidationError == rhs.relayValidationError
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
&& lhs.endpointId == rhs.endpointId
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
&& lhs.storageLoadFailed == rhs.storageLoadFailed
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
&& lhs.isCleaningStorage == rhs.isCleaningStorage
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
}
}
@MainActor
final class SettingsModel: ObservableObject {
@Published private(set) var state: SettingsState
/// Set by the view to request the receive-folder picker (macOS).
@Published var pendingReceiveFolderPick = false
private let environment: PlatformEnvironment
private let deviceInfoProvider: DeviceInfoProvider
private let fileSystemService: FileSystemService
private let repository: CoreGateway
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let messages: UiMessageController
private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
private var hasRelayConfigurationDraft = false
private var cancellables = Set<AnyCancellable>()
init(
environment: PlatformEnvironment,
deviceInfoProvider: DeviceInfoProvider,
fileSystemService: FileSystemService,
repository: CoreGateway,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
messages: UiMessageController,
bugReports: BugReportService,
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
) {
self.environment = environment
self.deviceInfoProvider = deviceInfoProvider
self.fileSystemService = fileSystemService
self.repository = repository
self.preferences = preferences
self.notifications = notifications
self.messages = messages
self.bugReports = bugReports
self.diagnosticsIncluded = diagnosticsIncluded
self.state = SettingsState(
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
appVersion: environment.appVersion
)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
let previousFolder = self.state.receiveFolder
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode
self.state.relayURLs = prefs.relayConfiguration.relayURLs
self.state.relayConfigurationIsDirty = false
}
if folder != previousFolder { Task { await self.validateFolder(folder) } }
}
.store(in: &cancellables)
repository.statePublisher
.sink { [weak self] coreState in
guard let self else { return }
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|| (coreState.status?.activeShares ?? 0) > 0
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
self.state.hasActiveNetworkWork = hasActiveWork
self.state.endpointId = coreState.status?.endpointId
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
self.state.relayApplyErrorKey = nil
}
}
.store(in: &cancellables)
refreshNotificationPermission()
loadDeviceInfo()
}
func selectSection(_ section: SettingsSection) {
state.selectedSection = section
if section == .about || section == .bugReport {
loadDeviceInfo()
if section == .bugReport { refreshBugLogPreview() }
}
}
func setUsername(_ value: String) {
hasLocalUsernameDraft = true
state.username = value
usernamePersistTask?.cancel()
usernamePersistTask = Task {
try? await Task.sleep(nanoseconds: 350_000_000)
if Task.isCancelled { return }
preferences.setUsername(value)
}
}
func setThemeMode(_ mode: ThemeMode) { preferences.setThemeMode(mode) }
func chooseReceiveFolder() {
if !fileSystemService.supportsCustomReceiveFolders { return }
pendingReceiveFolderPick = true
}
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() }
/// Whether the current receive folder is the platform default (so the reset
/// action can be hidden when it would be a no-op). Compared by location, not
/// display name, which can differ once resolved.
var isUsingDefaultReceiveFolder: Bool {
guard let folder = state.receiveFolder else { return true }
let fallback = fileSystemService.defaultReceiveFolder()
return folder.kind == fallback.kind && folder.value == fallback.value
}
/// Ask the OS for notification permission. This is the only time the app can
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
func requestNotifications() {
Task {
let permission = await notifications.requestPermission()
state.notificationPermission = permission
if permission == .unsupported {
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
}
}
}
func setDiagnosticsEnabled(_ enabled: Bool) {
if !diagnosticsIncluded { return }
Task {
preferences.setDiagnosticsEnabled(enabled)
messages.show(UiMessage(
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
tone: .success
))
}
}
// MARK: - Network
func setRelayMode(_ mode: RelayPreferenceMode) {
hasRelayConfigurationDraft = true
state.relayMode = mode
if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
updateRelayConfigurationDraft()
}
func setRelayURL(_ value: String, at index: Int) {
guard state.relayURLs.indices.contains(index) else { return }
hasRelayConfigurationDraft = true
state.relayURLs[index] = value
updateRelayConfigurationDraft()
}
func addRelayURL() {
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
hasRelayConfigurationDraft = true
state.relayURLs.append("")
updateRelayConfigurationDraft()
}
func removeRelayURL(at index: Int) {
guard state.relayURLs.indices.contains(index) else { return }
hasRelayConfigurationDraft = true
state.relayURLs.remove(at: index)
if state.relayURLs.isEmpty { state.relayURLs = [""] }
updateRelayConfigurationDraft()
}
func applyRelayConfiguration() {
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
let configuration: RelayConfiguration
do {
configuration = try RelayConfigurationValidator.validate(
mode: state.relayMode,
relayURLs: state.relayURLs,
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
)
} catch let error as RelayConfigurationValidationError {
state.relayValidationError = error
state.relayApplyErrorKey = nil
return
} catch {
return
}
let coreState = repository.state
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|| (coreState.status?.activeShares ?? 0) > 0
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
guard !hasActiveWork else {
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
return
}
let previousConfiguration = preferences.preferences.relayConfiguration
state.relayValidationError = nil
state.relayApplyErrorKey = nil
state.isApplyingRelayConfiguration = true
Task {
let applyResult = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: configuration
)
switch applyResult {
case .success:
hasRelayConfigurationDraft = false
preferences.setRelayConfiguration(configuration)
state.isApplyingRelayConfiguration = false
state.relayConfigurationIsDirty = false
messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success))
case .failure(let error):
if let lifecycleError = error as? CoreNetworkLifecycleError {
state.isApplyingRelayConfiguration = false
switch lifecycleError {
case .activeNetworkWork:
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
case .transitionInProgress:
state.relayApplyErrorKey = L10n.Relay.applyFailed
}
return
}
let rollbackResult = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: previousConfiguration
)
state.isApplyingRelayConfiguration = false
if case .success = rollbackResult {
state.relayApplyErrorKey = L10n.Relay.applyFailed
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
} else {
state.relayApplyErrorKey = L10n.Relay.restoreFailed
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
}
}
}
}
private func updateRelayConfigurationDraft() {
state.relayValidationError = nil
state.relayApplyErrorKey = nil
let saved = preferences.preferences.relayConfiguration
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
}
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
func setBugExpected(_ value: String) { state.bugExpected = value }
func setBugSteps(_ value: String) { state.bugSteps = value }
func setBugContact(_ value: String) { state.bugContact = value }
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
func submitBugReport(onSuccess: @escaping () -> Void = {}) {
if state.isSubmittingBugReport { return }
Task {
let snapshot = state
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
if what.isEmpty {
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
return
}
if expected.isEmpty {
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
return
}
state.isSubmittingBugReport = true
let result = await bugReports.submit(
BugReportDraft(
whatHappened: what, expected: expected, steps: snapshot.bugSteps,
contact: snapshot.bugContact, includeLogs: snapshot.bugIncludeLogs
),
deviceInfo: snapshot.deviceInfo
)
switch result {
case .success:
state.isSubmittingBugReport = false
state.bugWhatHappened = ""
state.bugExpected = ""
state.bugSteps = ""
state.bugContact = ""
state.bugIncludeLogs = true
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
onSuccess()
case .failure:
state.isSubmittingBugReport = false
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
}
}
}
func openNotificationSettings() {
Task {
if case .failure = await notifications.openSettings() {
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
}
}
}
/// Re-read the OS permission (called on appear and when returning to the
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
func refreshNotificationPermission() {
Task {
state.notificationPermission = await notifications.refreshPermission()
}
}
// MARK: - Storage
/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
/// before the core is ready: it keeps the spinner up and waits for the core to
/// finish initializing (it starts asynchronously at launch) rather than bailing.
func loadStorageUsage() {
if state.isCalculatingStorage { return }
state.isCalculatingStorage = true
state.storageLoadFailed = false
let tempDir = NSTemporaryDirectory()
Task {
// The core initializes asynchronously at launch; poll briefly so opening
// Storage early doesn't leave the summary stuck.
var attempts = 0
while !repository.state.isInitialized && attempts < 100 {
try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1
}
let coreResult = await repository.storageUsage()
let artifactsResult = await repository.receivedArtifacts()
guard case .success(let core) = coreResult,
case .success(let artifacts) = artifactsResult else {
state.isCalculatingStorage = false
state.storageLoadFailed = true
return
}
let diskSizes = await Task.detached {
let received = artifacts.reduce(UInt64(0)) { total, artifact in
total + SettingsModel.fileSize(artifact.locator)
}
return (received, SettingsModel.directorySize(tempDir))
}.value
let breakdown = StorageBreakdown(
receivedFiles: diskSizes.0,
transferCache: core.blobStoreBytes,
appData: core.appDataBytes,
temporary: diskSizes.1
)
state.storage = breakdown
state.isCalculatingStorage = false
}
}
/// Deletes every send/receive transfer via the core, freeing the imported
/// shared-file content and clearing history. Node identity and received files
/// are left untouched.
func deleteAllTransfers() {
if state.isDeletingTransfers { return }
state.isDeletingTransfers = true
Task {
var failures = 0
for id in repository.state.transfers.map(\.transferId) {
if case .failure = await repository.delete(transferId: id) { failures += 1 }
}
_ = await repository.refresh()
state.isDeletingTransfers = false
if failures == 0 {
loadStorageUsage()
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
} else {
messages.error(InvitationError.deleteRecordsFailed)
}
}
}
/// Reclaims disk space the core's transfer deletion doesn't touch: the app's
/// temporary directory (leftover picker/staging copies) and any stray `.Trash`
/// folders that accumulate inside app-owned directories. Never touches received
/// files, the core database, or user-chosen receive folders.
func freeUpSpace() {
if state.isCleaningStorage { return }
// Purging staging while a transfer is mid-flight could break it.
let hasActive = repository.state.transfers.contains {
$0.status == .sharing || $0.status == .importing || $0.status == .receiving
}
if hasActive {
messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning))
return
}
state.isCleaningStorage = true
let tempDir = NSTemporaryDirectory()
let dataDir = environment.defaultCoreDataDir
// Only clean the receive folder's trash when it is app-owned (iOS fixed
// Documents), never a user-chosen macOS folder like ~/Downloads.
let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value
Task {
let freed = await Task.detached {
SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot)
}.value
state.isCleaningStorage = false
loadStorageUsage()
messages.show(UiMessage(
text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))),
tone: .success
))
}
}
/// Deletes temp-directory contents and `.Trash` folders under the given roots,
/// returning the number of bytes reclaimed. Runs off the main actor.
nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 {
let fm = FileManager.default
var freed: UInt64 = 0
// Empty the temporary directory.
if let entries = try? fm.contentsOfDirectory(atPath: tempDir) {
for name in entries {
let path = (tempDir as NSString).appendingPathComponent(name)
freed += itemSize(path)
try? fm.removeItem(atPath: path)
}
}
// Remove stray `.Trash` folders inside app-owned directories.
for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) {
for trash in trashDirectories(under: root) {
freed += directorySize(trash)
try? fm.removeItem(atPath: trash)
}
}
return freed
}
/// Paths of every directory named `.Trash` under `root` (not descending into them).
private nonisolated static func trashDirectories(under root: String) -> [String] {
let url = URL(fileURLWithPath: root, isDirectory: true)
guard let enumerator = FileManager.default.enumerator(
at: url, includingPropertiesForKeys: [.isDirectoryKey]
) else { return [] }
var result: [String] = []
for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" {
result.append(fileURL.path)
enumerator.skipDescendants()
}
return result
}
/// Allocated size of a file or directory (0 if missing).
private nonisolated static func itemSize(_ path: String) -> UInt64 {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 }
return isDirectory.boolValue ? directorySize(path) : fileSize(path)
}
nonisolated static func fileSize(_ path: String) -> UInt64 {
let values = try? URL(fileURLWithPath: path).resourceValues(
forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
)
guard values?.isRegularFile == true else { return 0 }
return UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
}
/// Recursive size of every regular file under `path` (0 if missing).
nonisolated static func directorySize(_ path: String) -> UInt64 {
let url = URL(fileURLWithPath: path)
guard let enumerator = FileManager.default.enumerator(
at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
) else { return 0 }
var total: UInt64 = 0
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey])
guard values?.isRegularFile == true else { continue }
total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
}
return total
}
private func loadDeviceInfo() {
if state.isLoadingDeviceInfo { return }
state.isLoadingDeviceInfo = true
Task {
let info = await deviceInfoProvider.load()
state.deviceInfo = info
state.isLoadingDeviceInfo = false
}
}
private func refreshBugLogPreview() {
Task {
let bytes = await bugReports.previewLogBytes()
state.bugLogPreviewBytes = bytes
}
}
private func validateFolder(_ folder: ReceiveFolder) async {
state.isValidatingFolder = true
let status = await fileSystemService.validateReceiveFolder(folder)
state.folderAccessStatus = status
state.isValidatingFolder = false
}
}

View File

@@ -0,0 +1,184 @@
import SwiftUI
import SFSafeSymbols
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
/// navigation. The model stays the source of truth via a derived path binding.
struct SettingsScreen: View {
@ObservedObject var model: SettingsModel
let windowClass: WindowClass
@State private var showBugReport = false
private var path: Binding<[SettingsSection]> {
Binding(
get: {
switch model.state.selectedSection {
case .overview: return []
case .bugReport: return [.about, .bugReport]
case let section: return [section]
}
},
set: { newPath in model.selectSection(newPath.last ?? .overview) }
)
}
var body: some View {
NavigationStack(path: path) {
Form {
#if os(iOS)
// iOS suspends the app in the background, so serving/receiving
// can't run indefinitely there (unlike macOS). Tell users up
// front so the platform limit doesn't read as a bug.
Section {
VStack(alignment: .leading, spacing: 6) {
Label(String(localized: L10n.Settings.iosBackgroundNoticeTitle), systemSymbol: .moonZzz)
.font(.subheadline.weight(.semibold))
Text(String(localized: L10n.Settings.iosBackgroundNoticeBody))
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
#endif
Section {
NavigationLink(value: SettingsSection.preferences) {
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
}
NavigationLink(value: SettingsSection.appearance) {
SettingsRow(icon: .sunMax, title: String(localized: L10n.Appearance.title), value: themeModeLabel(model.state.themeMode))
}
}
Section {
NavigationLink(value: SettingsSection.notifications) {
SettingsRow(icon: .bell, title: String(localized: L10n.Notifications.title), value: nil)
}
NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
}
}
Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) {
SettingsRow(
icon: .network,
title: String(localized: L10n.Settings.networkTitle),
value: relayModeLabel(model.state.relayMode)
)
}
}
Section {
NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(String(localized: L10n.Settings.title)))
.navigationDestination(for: SettingsSection.self) { section in
sectionForm(section)
}
}
}
@ViewBuilder
private func sectionForm(_ section: SettingsSection) -> some View {
let content = Form {
SettingsSectionContent(model: model, section: section)
}
.formStyle(.grouped)
.navigationTitle(Text(String(localized: section.titleKey)))
if section == .about {
content
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showBugReport = true
} label: {
Label(String(localized: L10n.About.bugReport), systemSymbol: .ladybug)
}
}
}
.sheet(isPresented: $showBugReport) {
BugReportSheet(model: model)
}
} else {
content
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
private struct SettingsSectionContent: View {
@ObservedObject var model: SettingsModel
let section: SettingsSection
var body: some View {
switch section {
case .overview:
EmptyView()
case .preferences:
PreferencesSettings(model: model)
case .appearance:
AppearanceSettings(model: model)
case .notifications:
NotificationSettings(model: model)
case .network:
NetworkSettings(model: model)
case .storage:
StorageSettings(model: model)
case .about:
AboutSettings(model: model)
case .bugReport:
BugReportSettings(model: model)
}
}
}
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
switch mode {
case .automatic: return String(localized: L10n.Relay.modeAutomatic)
case .strictCustom: return String(localized: L10n.Relay.modeCustom)
case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback)
case .localOnly: return String(localized: L10n.Relay.modeLocalOnly)
}
}
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
switch mode {
case .automatic: return L10n.Relay.modeAutomaticDescription
case .strictCustom: return L10n.Relay.modeCustomDescription
case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription
case .localOnly: return L10n.Relay.modeLocalOnlyDescription
}
}
struct SettingsRow: View {
let icon: SFSymbol
let title: String
let value: String?
var body: some View {
HStack(spacing: 12) {
Image(systemSymbol: icon)
.foregroundStyle(.tint)
.frame(width: 26)
Text(title).foregroundStyle(.primary)
Spacer()
if let value {
Text(value).foregroundStyle(.secondary).lineLimit(1)
}
}
}
}
func themeModeLabel(_ mode: ThemeMode) -> String {
switch mode {
case .system: return String(localized: L10n.Appearance.systemMode)
case .light: return String(localized: L10n.Appearance.lightMode)
case .dark: return String(localized: L10n.Appearance.darkMode)
}
}

View File

@@ -0,0 +1,523 @@
import SwiftUI
import SFSafeSymbols
/// Settings section detail views, rebuilt as native `Form` content. Each view is
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
struct PreferencesSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section(String(localized: L10n.Field.username)) {
TextField(String(localized: L10n.Field.username),
text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
}
if model.state.supportsCustomReceiveFolders {
Section(String(localized: L10n.Preferences.receiveFolderTitle)) {
LabeledContent {
Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder)
} label: {
Label {
Text(model.state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
.lineLimit(1)
.truncationMode(.middle)
} icon: {
Image(systemSymbol: .folder)
}
}
if !model.isUsingDefaultReceiveFolder {
Button(String(localized: L10n.Button.resetDefault), role: .cancel, action: model.resetReceiveFolder)
}
}
}
}
}
struct AppearanceSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Picker(String(localized: L10n.Appearance.title),
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
ForEach(ThemeMode.allCases, id: \.self) { mode in
Text(themeModeLabel(mode)).tag(mode)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
}
}
struct NotificationSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
switch model.state.notificationPermission {
case .notDetermined:
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
case .granted:
// Allowed the OS Settings app is where you disable or fine-tune.
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
case .denied:
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
case .unsupported:
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
}
}
.onAppear { model.refreshNotificationPermission() }
}
}
struct NetworkSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Picker(
"",
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
) {
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
Text(relayModeLabel(mode)).tag(mode)
}
}
.pickerStyle(.inline)
.labelsHidden()
.disabled(model.state.isApplyingRelayConfiguration)
} header: {
Text(String(localized: L10n.Settings.networkTitle))
} footer: {
Text(String(localized: relayModeDescription(model.state.relayMode)))
}
Section {
Label {
Text(String(localized: L10n.Relay.privacyDescription))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemSymbol: .lockShield)
}
.foregroundStyle(.secondary)
}
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
Section {
Text(L10n.Approval.endpointId(deviceId: endpointId))
.font(.footnote.monospaced())
.textSelection(.enabled)
}
}
if model.state.relayMode.usesCustomRelayURLs {
Section {
if model.state.relayMode == .strictCustom {
Label {
Text(String(localized: L10n.Relay.strictWarning))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemSymbol: .exclamationmarkShieldFill)
}
.foregroundStyle(.orange)
}
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
VStack(alignment: .leading, spacing: 6) {
HStack {
TextField(
"",
text: Binding(
get: {
model.state.relayURLs.indices.contains(index)
? model.state.relayURLs[index]
: ""
},
set: { model.setRelayURL($0, at: index) }
),
// `Text(verbatim:)` avoids macOS markdown-linkifying the
// URL-shaped placeholder into a purple link.
prompt: Text(verbatim: "https://relay.example.com")
)
.labelsHidden()
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.disabled(model.state.isApplyingRelayConfiguration)
Button(role: .destructive) {
model.removeRelayURL(at: index)
} label: {
Image(systemSymbol: .minusCircleFill)
}
.buttonStyle(.borderless)
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
.disabled(model.state.isApplyingRelayConfiguration)
}
if let error = model.state.relayValidationError, error.urlIndex == index {
Text(relayValidationMessage(error))
.font(.caption)
.foregroundStyle(.red)
}
}
}
Button(action: model.addRelayURL) {
Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
}
.disabled(
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|| model.state.isApplyingRelayConfiguration
)
} header: {
Text(String(localized: L10n.Relay.customUrlsLabel))
} footer: {
Text(String(localized: L10n.Relay.customUrlsHelp))
}
}
if let error = model.state.relayValidationError, error.urlIndex == nil {
Section {
Label {
Text(relayValidationMessage(error))
} icon: {
Image(systemSymbol: .exclamationmarkTriangleFill)
}
.foregroundStyle(.red)
}
}
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
Section {
Label {
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
} icon: {
Image(systemSymbol: .exclamationmarkTriangleFill)
}
.foregroundStyle(.red)
}
}
Section {
Button(action: model.applyRelayConfiguration) {
HStack {
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
if model.state.isApplyingRelayConfiguration {
Spacer()
ProgressView()
}
}
}
.disabled(
!model.state.relayConfigurationIsDirty
|| model.state.isApplyingRelayConfiguration
|| model.state.hasActiveNetworkWork
)
} footer: {
Text(String(localized: L10n.Relay.applyRestartDescription))
}
}
}
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
switch error {
case .missingURL:
return String(localized: L10n.Relay.validationMissingUrl)
case .tooManyURLs:
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
case .httpsRequired(let index):
return L10n.Relay.validationHttpsRequired(line: index + 1)
case .invalidURL(let index):
return L10n.Relay.validationInvalidUrl(line: index + 1)
case .duplicateURL(let index):
return L10n.Relay.validationDuplicateUrl(line: index + 1)
}
}
struct StorageSettings: View {
@ObservedObject var model: SettingsModel
@State private var showDeleteConfirmation = false
private var isBusy: Bool {
model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers
}
var body: some View {
Section {
usageContent
} header: {
HStack {
Text(String(localized: L10n.Storage.usageHeader))
Spacer()
if model.state.isCalculatingStorage {
ProgressView().controlSize(.small)
} else {
Button(action: model.loadStorageUsage) {
Label(String(localized: L10n.Storage.refresh), systemSymbol: .arrowClockwise)
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.disabled(isBusy)
.help(String(localized: L10n.Storage.refresh))
}
}
} footer: {
Text(String(localized: L10n.Storage.footer))
}
// Reclaim reversible junk (temp + trash) non-destructive to history.
Section {
Button(action: model.freeUpSpace) {
actionLabel(
title: L10n.Storage.freeUpSpace,
busyTitle: L10n.Storage.cleaning,
isBusy: model.state.isCleaningStorage,
symbol: .sparkles,
tint: .accentColor
)
}
// `.plain` so pressing the row dims the label instead of flipping it to
// the white selection-highlight that the default form button style uses.
.buttonStyle(.plain)
.disabled(isBusy)
} footer: {
Text(String(localized: L10n.Storage.freeUpSpaceCaption))
}
// Destructive: clears transfer history + cached share content.
Section {
Button {
showDeleteConfirmation = true
} label: {
actionLabel(
title: L10n.Storage.deleteTransfers,
busyTitle: L10n.Storage.deleting,
isBusy: model.state.isDeletingTransfers,
symbol: .trash,
tint: .red
)
}
.buttonStyle(.plain)
.disabled(isBusy)
} footer: {
Text(String(localized: L10n.Storage.deleteTransfersCaption))
}
.task { model.loadStorageUsage() }
.confirmationDialog(
Text(String(localized: L10n.Storage.deleteTransfers)),
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) {
model.deleteAllTransfers()
}
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
} message: {
Text(String(localized: L10n.Storage.deleteTransfersDescription))
}
}
@ViewBuilder
private var usageContent: some View {
if let storage = model.state.storage {
LabeledContent(String(localized: L10n.Storage.receivedFiles), value: formatBytes(storage.receivedFiles))
LabeledContent(String(localized: L10n.Storage.transferData), value: formatBytes(storage.transferCache))
LabeledContent(String(localized: L10n.Storage.appData), value: formatBytes(storage.appData))
LabeledContent(String(localized: L10n.Storage.temporary), value: formatBytes(storage.temporary))
LabeledContent(String(localized: L10n.Storage.total)) {
Text(formatBytes(storage.total)).fontWeight(.semibold)
}
} else if model.state.storageLoadFailed {
// Genuine failure (core reported an error) offer a retry.
Button(action: model.loadStorageUsage) {
Label(String(localized: L10n.Storage.unavailable), systemSymbol: .arrowClockwise)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
} else {
// Loading, or waiting for the core to finish starting.
HStack {
Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary)
Spacer()
ProgressView().controlSize(.small)
}
}
}
/// A tinted, full-width button label with a leading symbol and a trailing
/// spinner while busy. `.contentShape` keeps the whole row tappable.
private func actionLabel(
title: String.LocalizationValue,
busyTitle: String.LocalizationValue,
isBusy: Bool,
symbol: SFSymbol,
tint: Color
) -> some View {
HStack {
Label(String(localized: isBusy ? busyTitle : title), systemSymbol: symbol)
Spacer()
if isBusy {
ProgressView().controlSize(.small)
}
}
.foregroundStyle(tint)
.contentShape(Rectangle())
}
}
struct AboutSettings: View {
@ObservedObject var model: SettingsModel
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
var body: some View {
Section {
Text(String(localized: L10n.About.tagline)).font(.headline)
Text(String(localized: L10n.About.description)).foregroundStyle(.secondary)
}
Section(String(localized: L10n.About.isTitle)) {
AboutPoint(L10n.About.isDirect, .paperplane)
AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark)
AboutPoint(L10n.About.isInControl, .checkmarkShield)
AboutPoint(L10n.About.isEncrypted, .lock)
AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight)
}
Section(String(localized: L10n.About.isntTitle)) {
AboutPoint(L10n.About.isntCloud, .icloudSlash)
AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath)
AboutPoint(L10n.About.isntPublic, .megaphone)
}
Section(String(localized: L10n.About.privacyTitle)) {
AboutPoint(L10n.About.privacyCapability, .qrcode)
AboutPoint(L10n.About.privacyDeny, .handRaised)
AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight)
AboutPoint(L10n.About.privacyLocal, .internaldrive)
}
Section(String(localized: L10n.About.title)) {
LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion)
if let device = model.state.deviceInfo {
LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "")
LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem)
}
LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0")
Link(destination: Self.privacyPolicyURL) {
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
}
}
if DiagnosticsBuildConfig.included {
Section {
Toggle(isOn: Binding(
get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) }
)) {
Text(String(localized: L10n.Diagnostics.title))
}
}
}
}
}
/// Bug report presented as a sheet from About. Can be dismissed by swipe only
/// when empty; otherwise the Cancel button is required.
struct BugReportSheet: View {
@ObservedObject var model: SettingsModel
@Environment(\.dismiss) private var dismiss
private var isEmpty: Bool {
model.state.bugWhatHappened.isEmpty && model.state.bugExpected.isEmpty
&& model.state.bugSteps.isEmpty && model.state.bugContact.isEmpty
}
var body: some View {
NavigationStack {
Form {
BugReportSettings(model: model, onSubmitted: { dismiss() })
}
.formStyle(.grouped)
.navigationTitle(Text(String(localized: L10n.About.bugReport)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.cancel)) { dismiss() }
}
}
}
.interactiveDismissDisabled(!isEmpty)
}
}
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
private struct AboutPoint: View {
let key: String.LocalizationValue
let symbol: SFSymbol
init(_ key: String.LocalizationValue, _ symbol: SFSymbol) {
self.key = key
self.symbol = symbol
}
var body: some View {
Label {
Text(String(localized: key))
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemSymbol: symbol).foregroundStyle(.tint)
}
}
}
struct BugReportSettings: View {
@ObservedObject var model: SettingsModel
var onSubmitted: () -> Void = {}
var body: some View {
Section(String(localized: L10n.Bug.reportWhatLabel)) {
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical)
.lineLimit(3, reservesSpace: true)
.labelsHidden()
}
Section(String(localized: L10n.Bug.reportExpectedLabel)) {
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical)
.lineLimit(3, reservesSpace: true)
.labelsHidden()
}
Section(String(localized: L10n.Bug.reportStepsLabel)) {
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical)
.lineLimit(3, reservesSpace: true)
.labelsHidden()
}
Section(String(localized: L10n.Bug.reportContactLabel)) {
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
prompt: Text(String(localized: L10n.Bug.reportContactHint)))
.labelsHidden()
}
Section {
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
Text(String(localized: L10n.Bug.reportIncludeLogs))
}
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
Text(model.state.isSubmittingBugReport
? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit))
}
.disabled(model.state.isSubmittingBugReport)
}
}
}

View File

@@ -0,0 +1,50 @@
#if os(iOS)
import Foundation
import UIKit
/// Builds the iOS dependency graph, ported from `rememberIosAppDependencies`.
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)",
appVersion: version,
defaultCoreDataDir: applicationDataDirectory(),
defaultUsername: device.name.isEmpty ? "Receiver" : device.name
)
return AppDependencies(
environment: env,
deviceInfoProvider: IosDeviceInfoProvider(),
fileSystemService: IosFileSystemService(),
notificationService: LocalNotificationService(),
externalInvitations: externalInvitations
)
}
private func applicationDataDirectory() -> String {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("VniDrop").path
}
private struct IosDeviceInfoProvider: DeviceInfoProvider {
@MainActor
func load() async -> DeviceInfo {
let device = UIDevice.current
let battery: String? = {
let wasMonitoring = device.isBatteryMonitoringEnabled
device.isBatteryMonitoringEnabled = true
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
let level = device.batteryLevel
return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil
}()
return DeviceInfo(
deviceName: device.name,
deviceModel: device.model,
operatingSystem: "\(device.systemName) \(device.systemVersion)",
network: nil,
batteryLevel: battery
)
}
}
#endif

View File

@@ -0,0 +1,50 @@
#if os(macOS)
import Foundation
import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
appVersion: version,
defaultCoreDataDir: applicationDataDirectory(),
defaultUsername: host
)
return AppDependencies(
environment: env,
deviceInfoProvider: MacDeviceInfoProvider(),
fileSystemService: MacFileSystemService(),
notificationService: LocalNotificationService(),
externalInvitations: externalInvitations
)
}
private func applicationDataDirectory() -> String {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("VniDrop").path
}
private struct MacDeviceInfoProvider: DeviceInfoProvider {
func load() async -> DeviceInfo {
DeviceInfo(
deviceName: Host.current().localizedName,
deviceModel: modelIdentifier(),
operatingSystem: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
network: nil,
batteryLevel: nil
)
}
private func modelIdentifier() -> String? {
var size = 0
sysctlbyname("hw.model", nil, &size, nil, 0)
guard size > 0 else { return nil }
var model = [CChar](repeating: 0, count: size)
sysctlbyname("hw.model", &model, &size, nil, 0)
return String(cString: model)
}
}
#endif

View File

@@ -0,0 +1,98 @@
#if os(iOS)
import Foundation
import UIKit
import VnidropCore
/// Native iOS file system service.
/// App-owned Documents is the fixed receive folder; custom folders are not
/// supported because raw external picker URLs do not survive relaunch.
struct IosFileSystemService: FileSystemService {
var supportsCustomReceiveFolders: Bool { false }
func defaultReceiveFolder() -> ReceiveFolder {
let path = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)
.first?.path ?? ""
return ReceiveFolder(kind: .fileSystemPath, value: path, displayName: "Documents")
}
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
switch folder.kind {
case .fileSystemPath:
return FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
case .iosSecurityScopedUrl:
return validateSecurityScopedUrl(folder.value)
}
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool {
folder.kind == .fileSystemPath
&& folder.value.trimmingTrailingSlash == defaultReceiveFolder().value.trimmingTrailingSlash
}
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else {
return .failure(InvitationError.filesystemUnavailable)
}
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
return .failure(InvitationError.filesystemUnavailable)
}
let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:]) { success in
continuation.resume(returning: success)
}
}
}
return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable)
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
for path in paths {
try? FileManager.default.removeItem(atPath: path)
}
}
func sharePickedFiles(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
let url = URL(string: value) ?? URL(fileURLWithPath: value)
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
if FileManager.default.isWritableFile(atPath: url.path) {
return .writable
}
return .permissionRequired
}
}
extension PickedShareFile {
/// iOS shares by filesystem path (from `asCopy` picker temp files).
func toIosShareSource() -> ShareSource {
ShareSource(kind: .path, value: value, displayName: displayName, isDirectory: isDirectory)
}
}
private extension String {
var trimmingTrailingSlash: String {
var s = self
while s.hasSuffix("/") { s.removeLast() }
return s
}
}
#endif

View File

@@ -0,0 +1,71 @@
#if os(macOS)
import Foundation
import AppKit
import VnidropCore
/// macOS file system service. Mirrors the desktop JVM behavior: default Downloads
/// receive folder, custom folders enabled via security-scoped bookmarks, reveal in
/// Finder. The Rust core streams bytes; Swift passes filesystem paths.
struct MacFileSystemService: FileSystemService {
var supportsCustomReceiveFolders: Bool { true }
func defaultReceiveFolder() -> ReceiveFolder {
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads")
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
}
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { true }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
let url = URL(fileURLWithPath: folder.value, isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url])
return .success(())
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
for path in paths {
try? FileManager.default.removeItem(atPath: path)
}
}
func sharePickedFiles(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
// Re-acquire security-scoped access to every picked source (from the bookmark
// captured at pick time) and hold it across the whole share call. The core
// imports the bytes during shareFiles(), so access only needs to survive that
// call; without this, the import fails with EPERM under the App Store sandbox.
var scopedURLs: [URL] = []
for file in files {
guard let bookmark = file.securityScopeBookmark else { continue }
var stale = false
guard let url = try? URL(
resolvingBookmarkData: bookmark, options: .withSecurityScope,
relativeTo: nil, bookmarkDataIsStale: &stale
), url.startAccessingSecurityScopedResource() else { continue }
scopedURLs.append(url)
}
defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } }
let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
}
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
}
#endif

View File

@@ -0,0 +1,20 @@
import Foundation
/// Builds a `.vnd` invitation filename from a transfer name.
func invitationFileName(_ transferName: String) -> String {
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
let base = trimmed.isEmpty ? "invitation" : trimmed
let safe = base.components(separatedBy: CharacterSet.alphanumerics.inverted.subtracting(CharacterSet(charactersIn: "-_ ")))
.joined()
.replacingOccurrences(of: " ", with: "-")
let name = safe.isEmpty ? "invitation" : safe
return "\(name).\(vniDropInvitationExtension)"
}
/// Writes a temporary `.vnd` file for sharing/exporting.
func writeTemporaryInvitation(ticket: String, transferName: String) throws -> URL {
let dir = FileManager.default.temporaryDirectory
let url = dir.appendingPathComponent(invitationFileName(transferName))
try ticket.write(to: url, atomically: true, encoding: .utf8)
return url
}

View File

@@ -0,0 +1,132 @@
import SwiftUI
import UniformTypeIdentifiers
/// Root-level pickers that are NOT triggered from inside a sheet. Currently just
/// the receive-folder picker (Settings is presented in the tab's navigation
/// stack, not a sheet, so presenting from the root works).
struct PlatformPickers: ViewModifier {
@ObservedObject var settingsModel: SettingsModel
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: Binding(get: { settingsModel.pendingReceiveFolderPick }, set: { settingsModel.pendingReceiveFolderPick = $0 }),
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
settingsModel.onReceiveFolderPicked(PickerSupport.receiveFolder(from: url))
case .failure(let error):
if !error.isUserCancellation { settingsModel.onReceiveFolderPickFailed(error.technicalDetail) }
}
}
}
}
/// Send file/folder pickers. Must be attached to the composer view so the picker
/// presents from the composer's sheet, not the already-presenting root controller.
struct SendPickers: ViewModifier {
@ObservedObject var model: SendModel
func body(content: Content) -> some View {
// A single .fileImporter, switched between files and folders. Stacking two
// .fileImporter modifiers on one view silently breaks on iOS/macOS < 27:
// the second shadows the first, so "Choose files" never presents.
content
.fileImporter(
isPresented: Binding(
get: { model.pendingFilePick || model.pendingFolderPick },
set: { presented in
if !presented {
model.pendingFilePick = false
model.pendingFolderPick = false
}
}
),
allowedContentTypes: model.pendingFolderPick ? [.folder] : [.item],
allowsMultipleSelection: !model.pendingFolderPick
) { result in
let isDirectory = model.pendingFolderPick
model.pendingFilePick = false
model.pendingFolderPick = false
handleShareSelection(result, isDirectory: isDirectory)
}
}
private func handleShareSelection(_ result: Result<[URL], Error>, isDirectory: Bool) {
switch result {
case .success(let urls):
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
if files.isEmpty {
model.onFilePickFailed("The selected document could not be opened")
} else {
model.onFilesPicked(files)
}
case .failure(let error):
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
}
}
}
enum PickerSupport {
static func receiveFolder(from url: URL) -> ReceiveFolder {
#if os(iOS)
// External receive folders on iOS use security-scoped URLs; the core holds
// access while streaming. Store the URL string.
return ReceiveFolder(kind: .iosSecurityScopedUrl, value: url.absoluteString, displayName: url.lastPathComponent)
#else
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
#endif
}
static func pickedFile(from url: URL, isDirectory: Bool) -> PickedShareFile? {
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
#if os(iOS)
// Copy into a temporary sandbox location so the core can read the file
// after the picker/security scope ends. Folders are passed by path.
if isDirectory {
return PickedShareFile(value: url.path, displayName: url.lastPathComponent, isDirectory: true)
}
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("share-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let dest = tempDir.appendingPathComponent(url.lastPathComponent)
do {
try FileManager.default.copyItem(at: url, to: dest)
} catch {
return nil
}
let size = (try? dest.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
return PickedShareFile(
value: dest.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: true, isDirectory: false
)
#else
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
// Capture a security-scoped bookmark while the picker's scope is still held,
// so the core can re-acquire access to open the file at import time (under
// the App Store sandbox). Non-sandboxed builds don't need it but it's harmless.
let bookmark = try? url.bookmarkData(
options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil
)
return PickedShareFile(
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
)
#endif
}
}
extension View {
func platformPickers(settingsModel: SettingsModel) -> some View {
modifier(PlatformPickers(settingsModel: settingsModel))
}
func sendPickers(model: SendModel) -> some View {
modifier(SendPickers(model: model))
}
}

View File

@@ -0,0 +1,268 @@
#if os(iOS)
import UIKit
@preconcurrency import AVFoundation
@preconcurrency import CoreNFC
import UniformTypeIdentifiers
@MainActor
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
/// iOS invitation acquisition:
/// document picker, camera QR scanner, and NFC read.
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
private var documentResult: ((Result<String, Error>) -> Void)?
private var nfcReader: InvitationNfcReader?
private var qrController: QrScannerViewController?
var fileAvailability: ReceiveMethodAvailability { .available }
var qrAvailability: ReceiveMethodAvailability {
AVCaptureDevice.default(for: .video) != nil ? .available : .unavailable
}
var nfcAvailability: ReceiveMethodAvailability {
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
}
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
documentResult = onResult
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: true)
picker.delegate = self
picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.viewControllerUnavailable))
}
presenter.present(picker, animated: true)
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.viewControllerUnavailable))
}
ensureCameraAccess { [weak self] granted in
guard let self else { return }
guard granted else {
return onResult(.failure(InvitationError.cameraUnavailable))
}
let scanner = QrScannerViewController { result in
self.qrController = nil
onResult(result)
}
self.qrController = scanner
scanner.modalPresentationStyle = .fullScreen
presenter.present(scanner, animated: true)
}
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard NFCNDEFReaderSession.readingAvailable else {
return onResult(.failure(InvitationError.nfcUnavailable))
}
let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil
onResult(result)
}
nfcReader = reader
reader.start()
}
func cancel() {
nfcReader?.cancel()
nfcReader = nil
qrController?.cancelScan()
qrController = nil
documentResult = nil
}
// UIDocumentPickerDelegate
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let result = documentResult
documentResult = nil
result?(Result {
guard let url = urls.first else { throw InvitationError.invalidInvitationURL }
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
guard data.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
return try decodeInvitationBytes(data)
})
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
documentResult = nil
}
private func ensureCameraAccess(_ completion: @escaping (Bool) -> Void) {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
completion(true)
case .notDetermined:
// The permission callback is delivered back on the main queue.
nonisolated(unsafe) let completion = completion
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async { completion(granted) }
}
default:
completion(false)
}
}
}
/// Full-screen camera QR scanner, ported from `QrScannerViewController`.
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
private let onResult: (Result<String, Error>) -> Void
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var finished = false
init(onResult: @escaping (Result<String, Error>) -> Void) {
self.onResult = onResult
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let hint = UILabel(frame: view.bounds)
hint.text = "Point the camera at a VniDrop QR code"
hint.textColor = .white
hint.textAlignment = .center
hint.numberOfLines = 0
hint.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(hint)
let close = UIButton(type: .system)
close.setTitle("Cancel", for: .normal)
close.setTitleColor(.white, for: .normal)
close.frame = CGRect(x: 16, y: 52, width: 88, height: 36)
close.addAction(UIAction { [weak self] _ in self?.cancelScan() }, for: .touchUpInside)
view.addSubview(close)
configureSession()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if session.isRunning { session.stopRunning() }
}
func cancelScan() {
finish(.failure(InvitationError.cancelled))
}
private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
return finish(.failure(InvitationError.cameraUnavailable))
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.cameraUnavailable))
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
session.sessionPreset = .high
DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() }
}
// The metadata output delegate queue is `.main`, so hop back onto the main
// actor to touch view-controller state.
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
let value = metadataObjects
.compactMap { $0 as? AVMetadataMachineReadableCodeObject }
.first { $0.type == .qr }?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !value.isEmpty else { return }
MainActor.assumeIsolated { finish(.success(value)) }
}
private func finish(_ result: Result<String, Error>) {
if finished { return }
finished = true
if session.isRunning { session.stopRunning() }
if presentingViewController != nil {
dismiss(animated: true) { self.onResult(result) }
} else {
onResult(result)
}
}
}
/// NFC invitation reader, ported from `InvitationNfcReader`.
// Runs entirely on the NFC session's `.main` delegate queue.
final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
private let onResult: (Result<String, Error>) -> Void
private var session: NFCNDEFReaderSession?
private var finished = false
init(onResult: @escaping (Result<String, Error>) -> Void) {
self.onResult = onResult
}
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
reader.alertMessage = String(localized: L10n.Receive.nfcWaiting)
session = reader
reader.begin()
}
func cancel() {
session?.invalidate()
session = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
let result = Result<String, Error> {
let ticket = messages
.flatMap { $0.records }
.compactMap { payloadAsInvitation($0) }
.first
guard let ticket else { throw InvitationError.nfcFailed }
return ticket
}
session.invalidate()
finish(result)
}
private func finish(_ result: Result<String, Error>) {
if finished { return }
finished = true
session = nil
DispatchQueue.main.async { self.onResult(result) }
}
}
private func payloadAsInvitation(_ payload: NFCNDEFPayload) -> String? {
guard let type = String(data: payload.type, encoding: .utf8) else { return nil }
let data = payload.payload
if payload.typeNameFormat == .media && (type == vniDropInvitationMimeType || type.hasPrefix("text/")) {
return try? decodeInvitationBytes(data)
}
return nil
}
#endif

View File

@@ -0,0 +1,45 @@
#if os(macOS)
import AppKit
import UniformTypeIdentifiers
@MainActor
func makeReceiveInvitationActions() -> ReceiveInvitationActions { MacReceiveInvitationActions() }
/// macOS invitation acquisition: file picker only. QR (camera) and NFC are hidden
/// on the desktop, matching the availability model.
final class MacReceiveInvitationActions: ReceiveInvitationActions {
var fileAvailability: ReceiveMethodAvailability { .available }
var qrAvailability: ReceiveMethodAvailability { .hidden }
var nfcAvailability: ReceiveMethodAvailability { .hidden }
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if let vnd = UTType(filenameExtension: vniDropInvitationExtension) {
panel.allowedContentTypes = [vnd, .data, .text]
}
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.cancelled))
return
}
onResult(Result {
let data = try Data(contentsOf: url)
return try decodeInvitationBytes(data)
})
}
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.qrUnavailable))
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.nfcUnavailable))
}
func cancel() {}
}
#endif

View File

@@ -0,0 +1,49 @@
#if DIRECT_DISTRIBUTION && os(macOS)
import Combine
import Sparkle
import SwiftUI
/// Owns the Sparkle updater for the direct-download (.dmg) build.
///
/// Compiled only under `DIRECT_DISTRIBUTION`, so the App Store / TestFlight target
/// (which must not ship a self-updater) never compiles or links Sparkle. The feed
/// URL and public EdDSA key are read from Info.plist (`SUFeedURL`, `SUPublicEDKey`).
@MainActor
final class SparkleUpdaterController: ObservableObject {
private let updaterController: SPUStandardUpdaterController
/// Mirrors `SPUUpdater.canCheckForUpdates` so the menu item can disable itself
/// while a check is already in flight.
@Published private(set) var canCheckForUpdates = false
init() {
// `startingUpdater: true` begins the automatic background check schedule.
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
updaterController.updater
.publisher(for: \.canCheckForUpdates)
.assign(to: &$canCheckForUpdates)
}
func checkForUpdates() {
updaterController.checkForUpdates(nil)
}
}
/// Adds a "Check for Updates" item to the application menu (right after the
/// standard "About VniDrop" item), matching the macOS convention.
struct UpdatesCommands: Commands {
@ObservedObject var controller: SparkleUpdaterController
var body: some Commands {
CommandGroup(after: .appInfo) {
Button(String(localized: L10n.Updates.check)) {
controller.checkForUpdates()
}
.disabled(!controller.canCheckForUpdates)
}
}
}
#endif

View File

@@ -0,0 +1,158 @@
#if os(iOS)
import UIKit
@preconcurrency import CoreNFC
@MainActor
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
/// iOS invitation delivery: export via
/// document picker, native share via `UIActivityViewController`, and NFC write.
final class IosTransferShareActions: NSObject, TransferShareActions {
private var nfcWriter: InvitationNfcWriter?
var canUseNativeShare: Bool { true }
var nfcAvailability: NfcShareAvailability {
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
}
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(Result {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.modalPresentationStyle = .formSheet
try present(picker)
})
}
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(Result {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
controller.modalPresentationStyle = .formSheet
try present(controller)
})
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.nfcUnavailable))
return
}
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
self?.nfcWriter = nil
onResult(result)
}
nfcWriter = writer
writer.start()
}
func cancelNfcWrite() {
nfcWriter?.cancel()
nfcWriter = nil
}
@MainActor
private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else {
throw InvitationError.viewControllerUnavailable
}
presenter.present(controller, animated: true)
}
}
/// Writes a VniDrop invitation to a writable NDEF tag.
// Runs entirely on the NFC session's `.main` delegate queue.
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
private let ticket: String
private let onResult: (Result<Void, Error>) -> Void
private var session: NFCNDEFReaderSession?
private var finished = false
init(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
self.ticket = ticket
self.onResult = onResult
}
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting)
session = reader
reader.begin()
}
func cancel() {
session?.invalidate()
session = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let firstTag = tags.first else {
return finish(.failure(InvitationError.nfcFailed))
}
// CoreNFC completion handlers run on the session's `.main` queue; these
// framework values are safe to use there.
nonisolated(unsafe) let session = session
nonisolated(unsafe) let tag = firstTag
session.connect(to: tag) { [weak self] connectError in
guard let self else { return }
if let connectError { return self.finish(.failure(connectError)) }
tag.queryNDEFStatus { status, _, queryError in
if let queryError { return self.finish(.failure(queryError)) }
switch status {
case .notSupported:
self.finish(.failure(InvitationError.nfcFailed))
case .readOnly:
self.finish(.failure(InvitationError.nfcFailed))
default:
guard let message = self.invitationMessage() else {
return self.finish(.failure(InvitationError.nfcFailed))
}
tag.writeNDEF(message) { writeError in
if let writeError {
self.finish(.failure(writeError))
} else {
session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
session.invalidate()
self.finish(.success(()))
}
}
}
}
}
}
private func invitationMessage() -> NFCNDEFMessage? {
guard let type = vniDropInvitationMimeType.data(using: .utf8),
let payload = ticket.data(using: .utf8) else { return nil }
let record = NFCNDEFPayload(format: .media, type: type, identifier: Data(), payload: payload)
return NFCNDEFMessage(records: [record])
}
private func finish(_ result: Result<Void, Error>) {
if finished { return }
finished = true
session = nil
DispatchQueue.main.async { self.onResult(result) }
}
}
@MainActor
func topPresenter() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let keyWindow = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
var controller = keyWindow?.rootViewController
while let presented = controller?.presentedViewController {
controller = presented
}
return controller
}
#endif

View File

@@ -0,0 +1,48 @@
#if os(macOS)
import AppKit
import SwiftUI
@MainActor
func makePlatformShareActions() -> TransferShareActions { MacTransferShareActions() }
/// macOS invitation delivery, mirroring the iOS actions: save panel export and
/// `NSSharingServicePicker` native share. NFC is unavailable on macOS.
final class MacTransferShareActions: TransferShareActions {
var canUseNativeShare: Bool { true }
var nfcAvailability: NfcShareAvailability { .hidden }
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
let panel = NSSavePanel()
panel.nameFieldStringValue = invitationFileName(transferName)
panel.allowedContentTypes = []
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.cancelled))
return
}
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
}
}
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.noWindowAvailable))
return
}
let picker = NSSharingServicePicker(items: [url])
picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
onResult(.success(()))
} catch {
onResult(.failure(error))
}
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(.failure(InvitationError.nfcUnavailable))
}
func cancelNfcWrite() {}
}
#endif

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M520.4,431.72C495.8,464.52 443.32,530.12 420.36,577.68C390.84,636.72 403.96,699.04 446.6,731.84C487.6,758.08 549.92,758.08 592.56,730.2C633.56,700.68 646.68,636.72 620.44,577.68C597.48,530.12 546.64,464.52 520.4,431.72Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(302.875,217.566,-217.566,302.875,404.439,431.72)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<defs>
<mask id="Mask">
<g transform="matrix(1,-0,-0,1,0,0)"><image id="_Image2" width="1024px" height="1024px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAABadnRfAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAH6ElEQVR4nO3cv2udVRjA8ZM6CU0HFVTUTK2NWA1IUCdFg24KDkoN+GOwi4IgHfwDnGzVRVB0EQvi4ODYRWILdXMwWsQgOBRbQTvlihA0qX9Blfu+J/c55z6fzx+Q82S43zznvW9bCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkNlC9AAwwMLty8vLR25aPLR48EC9n7r752SyPZlc+mlr67dr9X5sywSA3iwcXlt77OZ9PmSy9cPXG5f3+ZAGCABdWXjg5aeXZnXY1sbGuauzOgz4X99fm63ds8/fGP077ycbAF0JuJpvf3Hmwt7sj50NAaArMc/mfjn9yU7IwftOAOhK1MP5K6c//ivo6H0lAHQl7tu5P977YDvs8H0jAHQl8uv5309+NndvBwgAXYn9BJ5/9cfQ8+ur+BoVzLtHN98+GD1DXTYAuhK+g19a/yZ6hJpsADCNpfNvztOHxgZAV8I3gFLK2Rfn5/1gAaArLQSgXD5+IXqEWuZpm4HZuOPcC9Ej1CIAMLUbzpyMHqESAYAB3jk1H7fn+fgtSKOJZwCllFI+PfF39AgVCABdaScA5cvn/okeYTxXABjmmQ/n4M+nAMBAr7wVPcF4c9AwMmnoClBKef396AnGEgC60lYAyvrn0ROMJAB0pbEA7Dz8XfQI4wgAXWksAOXn1b7/myAPAWGEIx/1/TdUAGCM4yeiJxil73yRTmtXgFJ2HtqMHmEEAaAr7QWgbK52/EagKwCMs/Ja9AQj2ADoSoMbQJksX4keYTAbAIy0+G70BMPZAOhKixtAKU98FT3BUAJAV9oMwNa9u9EjDOQKAKMdfTZ6gqFsAHSlzQ2gXFzZix5hGBsAjHfsqegJBrIB0JVGN4Dy7YOtTvbfbABQweqT0RMMIwBQwxvRAwzjCkBXml209+7q8nVAGwDUcGA9eoJBbAB0pdkNoFy8v93Zrs8GAFUcW4meYAgBgDpeih5gCFcAutLwmv3rUsPDXY8NAOq483D0BAMIAFSyFj3AAAIAlTwePcAAngHQlZav2Vdv7e+fBNoAoJJb7oueYHoCALU8Ej3A9AQAarkneoDpCQDUcjR6gOkJANSyHD3A9HwLQFda/haglEOT6AmmZQOAau6OHmBqAgDV9HcHEACo5rboAaYmAFDNYvQAUxMAqEYAIDEBgMQEABITAEhMACCx/j5O/U0MVCMAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwc/8CNInt3t3vKtwAAAAASUVORK5CYII="/>
</g>
</mask>
</defs>
<g mask="url(#Mask)">
<path d="M688,148L782,148C832,148 842,171.8 847.48,210L847.48,303.68L688,148Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(110.156,162.233,-162.233,110.156,683.48,144.6)"><stop offset="0" style="stop-color:rgb(242,221,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(192,132,252);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M236.68,148L338.36,148C366.24,148 387.56,170.96 387.56,198.84L387.56,564.56C387.56,597.36 372.8,620.32 372.8,646.56C372.8,725.28 436.76,787.6 522.04,787.6C607.32,787.6 668,725.28 668,646.56C668,620.32 656.52,597.36 656.52,564.56L656.52,198.84C656.52,170.96 677.84,148 705.72,148L781.16,148C817.24,148 846.76,177.52 846.76,213.6L846.76,564.56C846.76,738.4 704.08,879.44 522.04,879.44C340,879.44 194.04,738.4 194.04,564.56L194.04,374.32L220.28,374.32L220.28,305.44C195.68,305.44 176,297.24 176,280.84L176,246.4C176,231.64 187.48,220.16 202.24,220.16L236.68,220.16L236.68,148ZM256.36,239.84C251.44,239.84 248.16,244.76 248.16,249.68L248.16,275.92C248.16,282.48 253.08,285.76 259.64,285.76L282.6,285.76C289.16,285.76 292.44,280.84 292.44,274.28L292.44,251.32C292.44,244.76 287.52,239.84 280.96,239.84L256.36,239.84Z" style="fill:url(#_Linear1);"/>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(728.706,668.252,-668.252,728.706,176,148)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,62 @@
{
"fill": {
"linear-gradient": [
"extended-gray:1.00000,1.00000",
"display-p3:0.55433,0.59923,0.92884,1.00000"
]
},
"groups": [
{
"blend-mode": "normal",
"blur-material": null,
"layers": [
{
"image-name": "Mask.svg",
"name": "Mask"
}
],
"lighting": "individual",
"refractivity": {
"depth": 0.5,
"enabled": true,
"strength": 0
},
"shadow": {
"kind": "neutral",
"opacity": 0.6
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.8
}
},
{
"layers": [
{
"image-name": "Drop.svg",
"name": "Drop"
},
{
"image-name": "U.svg",
"name": "U"
}
],
"lighting": "combined",
"shadow": {
"kind": "neutral",
"opacity": 0.6
},
"translucency": {
"enabled": true,
"value": 0.4
}
}
],
"supported-platforms": {
"circles": [
"watchOS"
],
"squares": "shared"
}
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.9685",
"green" : "0.3315",
"red" : "0.6606"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>fr</string>
<string>es</string>
<string>it</string>
<string>de</string>
<string>pt</string>
<string>pl</string>
<string>nl</string>
<string>ru</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>VniDrop</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- macOS: a single instance only; re-launching activates the running app
instead of spawning another copy. -->
<key>LSMultipleInstancesProhibited</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>VniDrop Invitation</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.vnidrop.app.invitation</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<!-- Sparkle auto-update (direct-download .dmg build only). These keys are inert
in the App Store build, which never loads Sparkle (DIRECT_DISTRIBUTION off).
The feed is appcast.xml attached as an asset to each GitHub Release; the
/releases/latest/download/ path always redirects to the newest (non-
prerelease) release's copy, and its <enclosure> points at that same release's
.dmg — no GitHub Pages or repo commits needed. SUPublicEDKey must be the EdDSA
public key printed by Sparkle's `generate_keys` — replace the placeholder
before shipping (see apple/RELEASE-MACOS.md). -->
<key>SUFeedURL</key>
<string>https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>/vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NFCReaderUsageDescription</key>
<string>VniDrop uses NFC to read transfer invitation tags.</string>
<key>NSBonjourServices</key>
<array>
<string></string>
</array>
<key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>VniDrop needs local network access to send to other local devices if needed.</string>
<!-- iPadOS: a single scene only — no second window via Stage Manager / split
view. Mirrors the single-window macOS behavior. -->
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>VniDrop Invitation</string>
<key>UTTypeIdentifier</key>
<string>com.vnidrop.app.invitation</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>vnd</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.vnidrop.transfer</string>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- iOS: NFC reader formats. The iOS 26 SDK requires TAG here and rejects
NDEF at App Store upload (error 90778 "NDEF is disallowed"). Our
NFCNDEFReaderSession usage keeps working under the TAG entitlement. -->
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>TAG</string>
</array>
<!-- macOS App Sandbox: user-selected files for share/receive, and network
client/server for the local P2P transfer. These keys are ignored on iOS. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- Entitlements for the direct-download (Developer ID + notarized) macOS build.
Deliberately NOT sandboxed: a sandboxed app signed for Developer ID requires a
provisioning profile, whereas direct-distribution apps run outside the App
Store sandbox by convention. Gatekeeper trust here comes from the hardened
runtime (ENABLE_HARDENED_RUNTIME) plus notarization, not the sandbox. The App
Store target (VniDrop) keeps VniDrop.entitlements with the sandbox enabled.
Networking and user file access need no entitlements once unsandboxed. -->
<dict>
</dict>
</plist>

View File

@@ -0,0 +1,66 @@
import SwiftUI
/// Presents modal content in a native sheet. On phones it uses medium/large
/// detents with a grabber; on wider layouts the sheet is form-sized. Content is
/// wrapped in a `NavigationStack` so it gets a native title bar + Close button.
struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
@Binding var isPresented: Bool
let windowClass: WindowClass
let onDismiss: () -> Void
@ViewBuilder let drawerContent: () -> DrawerContent
func body(content: Content) -> some View {
content.sheet(
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
) {
SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone))
}
}
}
private struct PhoneDetents: ViewModifier {
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
content.frame(minWidth: 460, minHeight: 480)
}
}
}
private struct SheetChrome<Content: View>: View {
let onClose: () -> Void
@ViewBuilder let content: () -> Content
var body: some View {
NavigationStack {
ScrollView { content().padding(.top, 4) }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.close), action: onClose)
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
extension View {
func adaptiveDrawer<DrawerContent: View>(
isPresented: Binding<Bool>,
windowClass: WindowClass,
onDismiss: @escaping () -> Void,
@ViewBuilder content: @escaping () -> DrawerContent
) -> some View {
modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, drawerContent: content
))
}
}

View File

@@ -0,0 +1,50 @@
import SwiftUI
/// Native SwiftUI button styles. Purple accent comes from the app-wide `.tint`.
/// Full-width filled button (`.borderedProminent`). Apply `.fixedSize()` at the
/// call site to shrink it to its content.
struct PrimaryButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!enabled)
}
}
/// Full-width bordered (secondary) button.
struct SecondaryButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
}
.buttonStyle(.bordered)
.controlSize(.large)
.disabled(!enabled)
}
}
/// Borderless tinted text button.
struct QuietButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(title, action: action)
.buttonStyle(.borderless)
.disabled(!enabled)
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
// MARK: - StatusPill
enum PillTone { case neutral, success, warning, destructive, brand }
struct StatusPill: View {
let label: String
var tone: PillTone = .neutral
private var color: Color {
switch tone {
case .neutral: return .secondary
case .success, .brand: return VniDropColors.brandPurple
case .warning: return .orange
case .destructive: return .red
}
}
var body: some View {
HStack(spacing: 5) {
Circle().fill(color).frame(width: 6, height: 6)
Text(label).font(.caption).fontWeight(.medium).foregroundStyle(color).lineLimit(1)
}
.padding(.horizontal, 9).padding(.vertical, 4)
.background(color.opacity(0.14), in: Capsule())
}
}
// MARK: - ProgressRow
struct ProgressRow: View {
let labelKey: String.LocalizationValue
let progress: Double?
var detail: String? = nil
/// Pre-resolved label; when set it overrides `labelKey`.
var labelText: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
label.font(.subheadline).lineLimit(1)
Spacer()
if let progress {
Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
}
}
if let detail {
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
if let progress {
ProgressView(value: progress)
} else {
ProgressView().progressViewStyle(.linear)
}
}
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var label: some View {
if let labelText {
Text(labelText)
} else {
Text(String(localized: labelKey))
}
}
}
// MARK: - Field
/// Labeled text field using native styling. Renders cleanly both inside a `Form`
/// row and standalone (e.g. inside a sheet).
struct Field: View {
let label: String
@Binding var value: String
var minLines: Int = 1
var enabled: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
Group {
if minLines > 1 {
TextField(label, text: $value, axis: .vertical)
.lineLimit(minLines, reservesSpace: true)
} else {
TextField(label, text: $value)
}
}
.textFieldStyle(.roundedBorder)
.disabled(!enabled)
}
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
/// Cross-platform decoding of raw image bytes into a SwiftUI `Image`.
enum PlatformImage {
static func from(data: Data) -> Image? {
#if os(iOS)
guard let ui = UIImage(data: data) else { return nil }
return Image(uiImage: ui)
#else
guard let ns = NSImage(data: data) else { return nil }
return Image(nsImage: ns)
#endif
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,80 @@
import SwiftUI
import SFSafeSymbols
/// Bottom toast host driven by `UiMessageController`, ported from
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
/// a longer display duration.
struct SnackbarHost: View {
@ObservedObject var controller: UiMessageController
@State private var dismissTask: Task<Void, Never>?
var body: some View {
VStack {
Spacer()
if let message = controller.current {
content(for: message)
.frame(maxWidth: 520)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(.gray.opacity(0.25), lineWidth: 0.5))
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
.padding(.horizontal, 16)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
.id(message.id)
.onAppear { scheduleDismiss(for: message) }
}
}
.animation(.easeInOut(duration: 0.2), value: controller.current?.id)
}
@ViewBuilder
private func content(for message: UiMessage) -> some View {
let accent: Color = {
switch message.tone {
case .info: return VniDropColors.brandPurple
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}()
HStack(alignment: .center, spacing: 8) {
Circle().fill(accent).frame(width: 8, height: 8)
Text(message.text.resolved())
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
if let actionLabel = message.actionLabel {
Button(action: {
message.onAction?()
dismiss()
}) {
Text(actionLabel.resolved()).fontWeight(.semibold)
}
.buttonStyle(.borderless)
}
Button(action: dismiss) {
Image(systemSymbol: .xmark)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
.padding(.leading, 16)
.padding(.trailing, 4)
}
private func scheduleDismiss(for message: UiMessage) {
dismissTask?.cancel()
let seconds: UInt64 = message.tone == .error ? 6 : 4
dismissTask = Task {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if !Task.isCancelled { controller.advance() }
}
}
private func dismiss() {
dismissTask?.cancel()
controller.advance()
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
import Combine
/// A localizable UI string: either a catalog key or dynamic text, ported from
/// `UiText` in `ui/feedback/UiMessageController.kt`.
enum UiText: Equatable {
case resource(String.LocalizationValue) // Localizable.xcstrings key (use L10n.*)
case dynamic(String)
/// Resolves to display text. Keys go through the string catalog.
func resolved() -> String {
switch self {
case .dynamic(let value): return value
case .resource(let value): return String(localized: value)
}
}
}
enum UiMessageTone {
case info
case success
case warning
case error
}
struct UiMessage: Identifiable {
let id = UUID()
let text: UiText
var tone: UiMessageTone = .info
var actionLabel: UiText? = nil
var onAction: (() -> Void)? = nil
}
/// Queues user-facing messages (snackbars) and dismissal requests. Ported from
/// `UiMessageController.kt`. Errors that are user cancellations are suppressed.
@MainActor
final class UiMessageController: ObservableObject {
@Published private(set) var current: UiMessage?
private var queue: [UiMessage] = []
func show(_ message: UiMessage) {
if current == nil {
current = message
} else {
queue.append(message)
}
}
@discardableResult
func tryShow(_ message: UiMessage) -> Bool {
show(message)
return true
}
/// Called by the host when the current message is dismissed or times out.
func advance() {
if queue.isEmpty {
current = nil
} else {
current = queue.removeFirst()
}
}
/// Surfaces a user-facing error. Logs the technical detail; suppresses user
/// cancellations. Mirrors `UiMessageController.error(Throwable)`.
func error(_ error: Error) {
if error.isUserCancellation {
AppLogger.info("ui", "suppressed user cancellation", ["detail": error.technicalDetail])
return
}
AppLogger.error("ui", "user-facing error", error)
show(UiMessage(text: error.toUiText(), tone: .error))
}
func error(_ text: UiText) {
show(UiMessage(text: text, tone: .error))
}
}

View File

@@ -0,0 +1,219 @@
import Foundation
import VnidropCore
/// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error {
func toUiText() -> UiText {
if let invitation = self as? InvitationError {
return invitation.uiText
}
if let vni = self as? VnidropError {
switch vni {
case .Ticket:
return .resource(L10n.Error.invalidTicket)
case .Permission:
return .resource(L10n.Error.permission)
case .Filesystem:
return .resource(L10n.Error.filesystem)
case .FilesystemPermission:
return .resource(L10n.Error.filesystem)
case .DestinationExists:
return .resource(L10n.Error.destinationExists)
case .StorageFull:
return .resource(L10n.Error.storageFull)
case .Network:
return .resource(L10n.Error.network)
case .Transfer(let reason):
return transferUiText(reason)
case .Repository:
return .resource(L10n.Error.repository)
case .Cancelled:
return .resource(L10n.Error.generic)
case .InvalidInput:
return .resource(L10n.Error.invalidInput)
case .Initialization(let reason):
return initializationUiText(reason)
case .Internal(let reason):
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic)
}
/// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool {
if let invitation = self as? InvitationError, case .cancelled = invitation { return true }
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased()
if haystack.isEmpty {
// URLError / CocoaError cancellation without a message.
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
return (self as NSError).code == NSUserCancelledError
}
return haystack.contains("cancelled")
|| haystack.contains("canceled")
|| haystack.contains("user cancelled")
|| haystack.contains("user canceled")
}
/// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String {
if let vni = self as? VnidropError {
switch vni {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
.InvalidInput(let r), .Internal(let r):
return r
}
}
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
}
var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true }
switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
default: return true
}
}
}
/// Maps each semantic `InvitationError` case to a localized user-facing message.
/// This is the sole `InvitationError` `L10n` boundary: no substring guessing,
/// except for `.raw`, whose dynamic payload still falls through `reasonHints`.
extension InvitationError {
var uiText: UiText {
switch self {
case .empty:
return .resource(L10n.Error.invitationEmpty)
case .tooLarge, .unsupportedOperation, .noWindowAvailable,
.viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled:
return .resource(L10n.Error.generic)
case .invalidEncoding, .invalidInvitationURL:
return .resource(L10n.Error.invalidTicket)
case .shareEmpty:
return .resource(L10n.Error.shareEmpty)
case .coreNotInitialized:
return .resource(L10n.Error.startingUp)
case .filesystemUnavailable:
return .resource(L10n.Error.filesystem)
case .nfcUnavailable, .nfcFailed:
return .resource(L10n.Error.nfc)
case .cameraUnavailable:
return .resource(L10n.Error.camera)
case .selectionFailed:
return .resource(L10n.Error.selectionFailed)
case .deleteRecordsFailed:
return .resource(L10n.Error.repository)
case .raw(let reason):
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
}
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
/// to the substring hints, then a generic message.
func receiverReasonUiText(_ reason: String) -> UiText {
switch reason {
case "destination_exists":
return .resource(L10n.Error.destinationExists)
case "filesystem", "filesystem_permission_denied":
return .resource(L10n.Error.filesystem)
case "permission_denied", "approval-required", "approval-expired",
"unknown-transfer", "missing-endpoint-id", "invalid-receipt":
return .resource(L10n.Error.permission)
case "storage_full":
return .resource(L10n.Error.storageFull)
case "network":
return .resource(L10n.Error.network)
case "invalid_ticket":
return .resource(L10n.Error.invalidTicket)
case "transfer":
return .resource(L10n.Error.transfer)
case "repository", "repository-error":
return .resource(L10n.Error.repository)
case "invalid_input":
return .resource(L10n.Error.invalidInput)
case "initialization":
return .resource(L10n.Error.initialization)
case "cancelled", "internal":
return .resource(L10n.Error.generic)
default:
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
private func transferUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
return .resource(L10n.Error.permission)
}
return .resource(L10n.Error.transfer)
}
private func initializationUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("native") && detail.contains("library") {
return .resource(L10n.Error.missingNativeLibrary)
}
if detail.contains("socket") || detail.contains("bind") {
return .resource(L10n.Error.socketBind)
}
return .resource(L10n.Error.initialization)
}
private func reasonHints(_ detailRaw: String) -> UiText? {
let detail = detailRaw.lowercased()
if detail.isEmpty { return nil }
if detail.contains("still starting") || detail.contains("starting up") {
return .resource(L10n.Error.startingUp)
}
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
return .resource(L10n.Error.invitationEmpty)
}
if detail.contains("select at least one") || detail.contains("no files found") {
return .resource(L10n.Error.shareEmpty)
}
if detail.contains("camera") {
return .resource(L10n.Error.camera)
}
if detail.contains("nfc") || detail.contains("ndef")
|| (detail.contains("read-only") && detail.contains("tag"))
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
return .resource(L10n.Error.nfc)
}
if detail.contains("native") && detail.contains("library") {
return .resource(L10n.Error.missingNativeLibrary)
}
if detail.contains("socket") || detail.contains("bind") {
return .resource(L10n.Error.socketBind)
}
if detail.contains("device information") || detail.contains("device info") {
return .resource(L10n.Error.deviceInfo)
}
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|| detail.contains("not approved") || detail.contains("waiting for approval") {
return .resource(L10n.Error.permission)
}
if detail.contains("invalid ticket") || detail.contains("ticket error")
|| detail.contains("could not be read") || detail.contains("malformed")
|| detail.contains("invitation could not be opened") {
return .resource(L10n.Error.invalidTicket)
}
if detail.contains("selected")
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
return .resource(L10n.Error.selectionFailed)
}
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
return .resource(L10n.Error.selectionFailed)
}
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|| detail.contains("view controller") {
return .resource(L10n.Error.selectionFailed)
}
return nil
}

View File

@@ -0,0 +1,28 @@
import Foundation
import SFSafeSymbols
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
enum AppDestination: String, CaseIterable, Identifiable {
case send
case receive
case settings
var id: String { rawValue }
var labelKey: String.LocalizationValue {
switch self {
case .send: return L10n.Nav.send
case .receive: return L10n.Nav.receive
case .settings: return L10n.Nav.settings
}
}
/// SF Symbol approximating the Compose line icon.
var systemSymbol: SFSymbol {
switch self {
case .send: return .paperplane
case .receive: return .trayAndArrowDown
case .settings: return .gearshape
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
/// Semantic type scale mapped from the Material typography styles the Compose UI
/// uses, so screens reference the same names during the port.
enum VniType {
static let titleLarge = Font.system(size: 22, weight: .semibold)
static let bodyLarge = Font.system(size: 16)
static let bodyMedium = Font.system(size: 14)
static let bodySmall = Font.system(size: 12)
static let labelSmall = Font.system(size: 11, weight: .medium)
}

View File

@@ -0,0 +1,191 @@
import SwiftUI
/// Direct Compose port of the VniDrop semantic color tokens
/// (`shared/.../ui/theme/VniDropTheme.kt`). The app uses these semantic tokens
/// directly because a single SwiftUI/Material color scheme cannot represent the
/// full surface, border, and foreground stack.
struct VniDropColors {
let backgroundDefault: Color
let backgroundDashCanvas: Color
let backgroundDashSidebar: Color
let backgroundSurface75: Color
let backgroundSurface100: Color
let backgroundSurface200: Color
let backgroundSurface300: Color
let backgroundSurface400: Color
let backgroundMuted: Color
let backgroundControl: Color
let backgroundSelection: Color
let backgroundButton: Color
let backgroundOverlayHover: Color
let backgroundDialog: Color
let borderDefault: Color
let borderStrong: Color
let borderStronger: Color
let borderMuted: Color
let borderControl: Color
let foregroundDefault: Color
let foregroundLight: Color
let foregroundLighter: Color
let foregroundMuted: Color
let foregroundContrast: Color
let brandLink: Color
let brandButton: Color
let brandDefault: Color
let brand600: Color
let brand500: Color
let brand400: Color
let brand300: Color
let brand200: Color
let warningDefault: Color
let warning200: Color
let warning300: Color
let warning400: Color
let warning500: Color
let warning600: Color
let destructiveDefault: Color
let destructive200: Color
let destructive300: Color
let destructive400: Color
let destructive500: Color
let destructive600: Color
}
extension VniDropColors {
/// The single brand accent used app-wide as the SwiftUI tint. Mirrored by the
/// `AccentColor` asset (the OS-level global accent for the macOS sidebar etc.);
/// keep the two in sync.
static let brandPurple = Color.hsl(271, 91, 65)
static let light = VniDropColors(
backgroundDefault: .hsl(0, 0, 98.8),
backgroundDashCanvas: .hsl(0, 0, 97.3),
backgroundDashSidebar: .hsl(0, 0, 98.8),
backgroundSurface75: .hsl(0, 0, 100),
backgroundSurface100: .hsl(0, 0, 98.8),
backgroundSurface200: .hsl(0, 0, 95.3),
backgroundSurface300: .hsl(0, 0, 92.9),
backgroundSurface400: .hsl(0, 0, 89.8),
backgroundMuted: .hsl(0, 0, 96.9),
backgroundControl: .hsl(0, 0, 95.3),
backgroundSelection: .hsl(0, 0, 92.9),
backgroundButton: .hsl(0, 0, 91),
backgroundOverlayHover: .hsl(0, 0, 95.3),
backgroundDialog: .hsl(0, 0, 100),
borderDefault: .hsl(0, 0, 87.5),
borderStrong: .hsl(0, 0, 83.1),
borderStronger: .hsl(0, 0, 56.1),
borderMuted: .hsl(0, 0, 92.9),
borderControl: .hsl(0, 0, 78),
foregroundDefault: .hsl(0, 0, 9),
foregroundLight: .hsl(0, 0, 32.2),
foregroundLighter: .hsl(0, 0, 43.9),
foregroundMuted: .hsl(0, 0, 69.8),
foregroundContrast: .hsl(0, 0, 98.4),
brandLink: .hsl(271, 91, 65),
brandButton: .hsl(270, 95, 75),
brandDefault: .hsl(271, 91, 65),
brand600: .hsl(271, 81, 56),
brand500: .hsl(271, 91, 65),
brand400: .hsl(270, 95, 75),
brand300: .hsl(269, 97, 85),
brand200: .hsl(269, 100, 92),
warningDefault: .hsl(38.9, 100, 57.1),
warning200: .hsl(40, 81.8, 97.8),
warning300: .hsl(44.3, 100, 91.8),
warning400: .hsl(41.9, 100, 81.8),
warning500: .hsl(36.3, 85.7, 67.1),
warning600: .hsl(30.3, 80.3, 47.8),
destructiveDefault: .hsl(10.2, 77.9, 53.9),
destructive200: .hsl(0, 100, 99.4),
destructive300: .hsl(7.1, 100, 96.7),
destructive400: .hsl(7.1, 91.3, 91),
destructive500: .hsl(10.4, 77.1, 79.4),
destructive600: .hsl(9.9, 82, 43.5)
)
static let dark = VniDropColors(
backgroundDefault: .hsl(0, 0, 7.1),
backgroundDashCanvas: .hsl(0, 0, 7.1),
backgroundDashSidebar: .hsl(0, 0, 9),
backgroundSurface75: .hsl(0, 0, 9),
backgroundSurface100: .hsl(0, 0, 12.2),
backgroundSurface200: .hsl(0, 0, 12.9),
backgroundSurface300: .hsl(0, 0, 16.1),
backgroundSurface400: .hsl(0, 0, 16.1),
backgroundMuted: .hsl(0, 0, 14.1),
backgroundControl: .hsl(0, 0, 14.1),
backgroundSelection: .hsl(0, 0, 19.2),
backgroundButton: .hsl(0, 0, 18),
backgroundOverlayHover: .hsl(0, 0, 18),
backgroundDialog: .hsl(0, 0, 7.1),
borderDefault: .hsl(0, 0, 18),
borderStrong: .hsl(0, 0, 21.2),
borderStronger: .hsl(0, 0, 27.1),
borderMuted: .hsl(0, 0, 14.1),
borderControl: .hsl(0, 0, 22.4),
foregroundDefault: .hsl(0, 0, 98),
foregroundLight: .hsl(0, 0, 70.6),
foregroundLighter: .hsl(0, 0, 53.7),
foregroundMuted: .hsl(0, 0, 30.2),
foregroundContrast: .hsl(0, 0, 8.6),
brandLink: .hsl(270, 95, 75),
brandButton: .hsl(271, 81, 56),
brandDefault: .hsl(270, 95, 75),
brand600: .hsl(271, 91, 65),
brand500: .hsl(271, 81, 56),
brand400: .hsl(273, 67, 39),
brand300: .hsl(274, 66, 32),
brand200: .hsl(274, 87, 21),
warningDefault: .hsl(38.9, 100, 42.9),
warning200: .hsl(36.6, 100, 8),
warning300: .hsl(32.3, 100, 10.2),
warning400: .hsl(33.2, 100, 14.5),
warning500: .hsl(34.8, 90.9, 21.6),
warning600: .hsl(38.9, 100, 42.9),
destructiveDefault: .hsl(10.2, 77.9, 53.9),
destructive200: .hsl(10.9, 23.4, 9.2),
destructive300: .hsl(7.5, 51.3, 15.3),
destructive400: .hsl(6.7, 60, 20.6),
destructive500: .hsl(7.9, 71.6, 29),
destructive600: .hsl(9.7, 85.2, 62.9)
)
}
extension Color {
/// HSL constructor matching the Compose `hsl()` helper (hue in degrees,
/// saturation and lightness in percent).
static func hsl(_ hue: Double, _ saturation: Double, _ lightness: Double) -> Color {
let h = (hue.truncatingRemainder(dividingBy: 360) + 360)
.truncatingRemainder(dividingBy: 360) / 360
let s = min(max(saturation, 0), 100) / 100
let l = min(max(lightness, 0), 100) / 100
if s == 0 {
return Color(red: l, green: l, blue: l)
}
let q = l < 0.5 ? l * (1 + s) : l + s - l * s
let p = 2 * l - q
return Color(
red: hueToRgb(p, q, h + 1.0 / 3.0),
green: hueToRgb(p, q, h),
blue: hueToRgb(p, q, h - 1.0 / 3.0)
)
}
}
private func hueToRgb(_ p: Double, _ q: Double, _ input: Double) -> Double {
var t = input
if t < 0 { t += 1 }
if t > 1 { t -= 1 }
let value: Double
if t < 1.0 / 6.0 {
value = p + (q - p) * 6 * t
} else if t < 1.0 / 2.0 {
value = q
} else if t < 2.0 / 3.0 {
value = p + (q - p) * (2.0 / 3.0 - t) * 6
} else {
value = p
}
return min(1, max(0, value))
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
/// User-facing theme selection, mirrors `ThemeMode` in the Compose theme.
enum ThemeMode: String, CaseIterable, Codable, Sendable {
case system
case light
case dark
/// SwiftUI color-scheme override (`nil` follows the system).
var preferredColorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
func resolveDarkTheme(_ mode: ThemeMode, systemDark: Bool) -> Bool {
switch mode {
case .system: return systemDark
case .light: return false
case .dark: return true
}
}
private struct VniDropColorsKey: EnvironmentKey {
static let defaultValue = VniDropColors.light
}
extension EnvironmentValues {
/// Semantic VniDrop tokens for the active theme. Read with
/// `@Environment(\.vniColors) private var colors`.
var vniColors: VniDropColors {
get { self[VniDropColorsKey.self] }
set { self[VniDropColorsKey.self] = newValue }
}
}
/// Provides the semantic token set matching the resolved light/dark theme to the
/// whole subtree. Apply once near the app root.
struct VniDropTheme: ViewModifier {
let isDark: Bool
func body(content: Content) -> some View {
content
.environment(\.vniColors, isDark ? .dark : .light)
.tint(VniDropColors.brandPurple)
}
}
extension View {
func vniDropTheme(isDark: Bool) -> some View {
modifier(VniDropTheme(isDark: isDark))
}
}

Some files were not shown because too many files have changed in this diff Show More