83 Commits

Author SHA1 Message Date
fe97c21c7a fix(apple): keep the snackbar above the approval overlay
The earlier approval-modal fix folded SnackbarHost and the approval modal into a
single OverlayLayer child; nested that way the approval host's full-bleed clear
layer covered the toast, so snackbars stopped appearing.

Split them: rename OverlayLayer to ApprovalLayer (approval modal only) and hoist
SnackbarHost to a top-most direct child of the root ZStack, observing the live
`graph.messages` directly. The toast now renders above the overlay again.
2026-07-31 12:46:12 +02:00
c670dda0a9 fix(apple): run the notification delegate on the main actor (iOS crash)
Tapping an approval notification while the app was backgrounded crashed on iOS
with "Call must be made on main thread". The UNUserNotificationCenterDelegate
methods are `async` and nonisolated, so their continuation resumes off the main
thread at the return point — where UIKit synchronously runs state-restoration /
snapshot work, tripping the main-thread assertion. (The empty iOS `didReceive`
body didn't matter; even an empty async method returns off-main.)

Isolate NotificationPresenter to `@MainActor` so the delegate returns on the main
thread. `@preconcurrency` on the UNUserNotificationCenterDelegate conformance is
required because those requirements are nonisolated with non-Sendable UN*
parameters, which strict concurrency won't otherwise let a main-actor type
witness. The macOS branch's now-redundant `await MainActor.run { … }` is dropped.
2026-07-31 12:46:05 +02:00
ff391f5502 build(apple): publish prebuilt core bundle in release assets
Bundle the compiled Apple core — vnidrop.xcframework plus the generated UniFFI
bindings (Vnidrop.swift, a source file that lives outside the xcframework) — into
VnidropCore-<version>.zip with a checksum, and attach it to the GitHub Release.
This lets a consumer (e.g. Xcode Cloud, later) use the prebuilt core instead of
installing Rust and running build-core.sh.

No duplicate builds: the release job compiles the core once (build-apple-dmg ->
build-core.sh release), links it into the signed DMG, and package-core.sh only
zips that same output. Package.swift is unchanged (still binaryTarget(path:)).

- apple/scripts/package-core.sh: stage xcframework + Vnidrop.swift and zip them
  with a sha256sum/shasum-compatible checksum sidecar (macOS-native).
- Makefile: package-apple-core target.
- apple-release.yml: run package-apple-core after the DMG and upload the zip +
  checksum in the macOS artifact.
- assemble-release.sh: verify the core zip's checksum, copy it into the final
  assets, and list it in release-manifest.json + SHA256SUMS (+ fixture update).
2026-07-31 11:40:34 +02:00
9079c81409 build(apple): pin ARCHS to arm64 project-wide
The Rust core's macOS slice (vnidrop.xcframework) is built aarch64-apple-darwin
only, so every target is Apple-Silicon-only — not just the Release-Direct build.
Hoist ARCHS: arm64 from the VniDropDirect target into the project-wide base
settings so no configuration attempts a universal link that would fail looking
for x86_64 symbols. Intel Macs are unsupported (EOL with macOS 28).
2026-07-31 11:20:36 +02:00
5424da855e fix(apple): show receiver-approval modal on macOS release builds
The approval modal never appeared for a macOS sender: the receiver request
reached the core and even fired its notification, but the modal stayed hidden.

Root cause was observation, not presentation. `RootView` derived `approvals`
and `messages` as `@ObservedObject` in `init` from a freshly built `AppGraph`.
`init` runs on every view re-creation and each run makes a throwaway graph, so
those observed objects were repointed to a dead `ApprovalCoordinator` that never
receives core events — while the persisted `@StateObject graph` (and the models
wired to it) kept the live one. Debug happened not to re-init the view, so it
stayed on the live instance; release re-inits it, exposing the bug.

Move the snackbar + approval modal into an `OverlayLayer` child view that takes
the coordinator/messages as `@ObservedObject` and is constructed in `body` from
the persisted `graph`, so the subscription is always against the live instances.

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
2026-07-31 11:19:28 +02:00
56d19014d4 chore(release): prepare 0.2.4 2026-07-31 04:59:36 +02:00
51bf0abba2 fix(release): configure Store CLI before settings 2026-07-31 04:51:31 +02:00
e0fb84ccb9 chore(release): prepare 0.2.3 2026-07-30 22:22:11 +02:00
30025a4ebf fix(release): download Play APK media 2026-07-30 22:19:40 +02:00
50e9a6c1cc chore(release): prepare 0.2.2 2026-07-30 21:54:54 +02:00
224a8e0e7a fix(release): enforce Apple hardened runtime 2026-07-30 21:27:34 +02:00
efacfab213 fix(release): expose Apple notarization failures 2026-07-30 21:03:30 +02:00
Hammed Abass
0ec7618ce8 Merge pull request #39 from sudosylabs/feat/release-pipeline-fixes
fix(release): repair Apple and Android builds
2026-07-30 20:30:28 +02:00
8b75423b7a fix(release): repair Apple and Android builds 2026-07-30 20:26:27 +02:00
Hammed Abass
7236933b76 Merge pull request #38 from sudosylabs/feat/release-0.2.1
chore(release): prepare 0.2.1
2026-07-30 19:43:19 +02:00
fc732e1b77 chore(release): prepare 0.2.1 2026-07-30 19:27:57 +02:00
Hammed Abass
d097c82f6a Merge pull request #37 from sudosylabs/feat/microsoft-store-publishing
ci: automate Microsoft Store updates
2026-07-30 19:25:15 +02:00
caaa9a472d ci: automate Microsoft Store updates 2026-07-30 19:12:50 +02:00
Hammed Abass
4ce124da7c Merge pull request #36 from sudosylabs/feat/automated-release-versions
feat(release): automate derived store versions
2026-07-30 17:58:18 +02:00
94a8b3481b feat(release): automate derived store versions 2026-07-30 17:40:00 +02:00
Hammed Abass
6d908d8dc3 Merge pull request #35 from sudosylabs/feat/release-pipeline
ci: add coordinated cross-platform release pipeline
2026-07-28 11:58:18 +02:00
c6655da7db refactor(version): derive Apple build numbers 2026-07-28 11:27:45 +02:00
2d7982bbb9 ci: add coordinated release pipeline 2026-07-28 11:09:53 +02:00
Hammed Abass
52d4102308 Merge pull request #34 from sudosylabs/feat/unified-versioning
feat(release): unify cross-platform versioning
2026-07-28 08:45:02 +02:00
407a0d2d60 feat(release): unify cross-platform versioning 2026-07-28 08:22:58 +02:00
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
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
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
190 changed files with 8409 additions and 18667 deletions

1
.gitattributes vendored Normal file
View File

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

141
.github/workflows/android-release.yml vendored Normal file
View File

@@ -0,0 +1,141 @@
name: Android release package
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-release-${{ github.ref }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: Build signed Android APK and AAB
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- 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: Install Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
targets: aarch64-linux-android,x86_64-linux-android
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: android-release-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
android-release-cargo-1.91.0-
- name: Set up Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools platforms;android-36 build-tools;36.0.0"
- name: Set up Android NDK
id: setup-ndk
uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
with:
ndk-version: r27c
link-to-sdk: true
add-to-path: false
- name: Export Android NDK location
run: |
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
- name: Resolve canonical version
id: version
run: |
packaging/version/resolve-version.sh verify >/dev/null
echo "app=$(packaging/version/resolve-version.sh product)" >> "$GITHUB_OUTPUT"
echo "code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
- name: Validate signing configuration
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
run: |
for name in \
KEYSTORE_BASE64 \
KEYSTORE_PASSWORD \
KEY_ALIAS \
KEY_PASSWORD \
UPLOAD_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
echo "Missing Android release signing configuration: $name" >&2
exit 1
fi
done
- name: Decode upload keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
run: |
keystore="$RUNNER_TEMP/vnidrop-upload.jks"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore"
chmod 600 "$keystore"
test -s "$keystore"
echo "VNIDROP_ANDROID_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV"
- name: Build and verify signed release
env:
VNIDROP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
VNIDROP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
VNIDROP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
VNIDROP_ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
run: packaging/android/build-release.sh
- name: Remove upload keystore
if: always()
run: rm -f "$RUNNER_TEMP/vnidrop-upload.jks"
- name: Upload Android artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-android-release
path: build/release/android/
if-no-files-found: error
retention-days: 90
compression-level: 0
- name: Summarize Android package
run: |
echo "### Android release package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Version code: ${{ steps.version.outputs.code }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Signing: upload certificate verified" >> "$GITHUB_STEP_SUMMARY"

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

@@ -0,0 +1,168 @@
name: Apple release (macOS DMG)
# Builds, signs, notarizes, and uploads the direct-download macOS build:
# - a Developer IDsigned, notarized VniDrop-<version>.dmg,
# - a Sparkle appcast.xml.
#
# The central release workflow publishes these artifacts and updates Homebrew.
#
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
# Organizer / App Store Connect. This workflow only covers direct distribution.
#
# Called by the central tag-release workflow, or run manually to validate the
# signed/notarized direct-download artifact.
on:
workflow_call:
workflow_dispatch:
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
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 canonical version
id: version
run: |
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
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@0c5077e51419868618aeaa5fe8019c62421857d6 # 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: make build-apple-dmg
- name: Package prebuilt core
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
# skip building the core. See apple/scripts/package-core.sh.
run: make package-apple-core
- name: Upload notarization diagnostics
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
path: apple/dist/*.notary-log.json
if-no-files-found: ignore
retention-days: 14
- name: Generate appcast
env:
RELEASE_REPO: ${{ github.repository }}
run: apple/scripts/generate-appcast.sh
- 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/VniDrop-*.build-info.json
apple/dist/appcast.xml
apple/dist/VnidropCore-*.zip
apple/dist/VnidropCore-*.zip.sha256
if-no-files-found: error
retention-days: 14

View File

@@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
@@ -12,11 +14,14 @@ on:
- "config.mk"
- "make/**"
- ".github/workflows/apple.yml"
- "localization/**"
push:
branches:
- master
paths:
- "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
@@ -25,6 +30,7 @@ on:
- "config.mk"
- "make/**"
- ".github/workflows/apple.yml"
- "localization/**"
permissions:
contents: read
@@ -63,5 +69,19 @@ jobs:
- 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

@@ -5,6 +5,8 @@ on:
paths:
- ".github/workflows/linux-packages.yml"
- "packaging/linux/**"
- "packaging/version/**"
- "version.properties"
- "assets/linux/**"
- "desktopApp/**"
- "shared/**"
@@ -20,16 +22,8 @@ on:
- "Makefile"
- "config.mk"
- "make/**"
push:
tags:
- "v*.*.*"
workflow_call:
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions:
contents: read
@@ -88,16 +82,14 @@ jobs:
restore-keys: |
linux-deb-x64-cargo-1.91.0-
- name: Resolve version
- name: Resolve canonical version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Test and build Debian package
run: make package-deb VERSION=${{ steps.version.outputs.app }}
run: make package-deb
- name: Upload Debian artifact
if: github.event_name != 'pull_request'
@@ -193,16 +185,14 @@ jobs:
restore-keys: |
linux-rpm-x64-cargo-1.91.0-
- name: Resolve version
- name: Resolve canonical version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Build RPM package
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
run: make package-rpm
- name: Upload RPM artifact
if: github.event_name != 'pull_request'
@@ -220,74 +210,3 @@ jobs:
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

53
.github/workflows/release-checks.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Release pipeline checks
on:
pull_request:
paths:
- ".github/workflows/android-release.yml"
- ".github/workflows/apple-release.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-checks.yml"
- ".github/workflows/release.yml"
- ".github/workflows/windows-store.yml"
- "packaging/android/**"
- "packaging/release/**"
- "packaging/version/**"
- "version.properties"
- "Makefile"
push:
branches:
- master
paths:
- ".github/workflows/android-release.yml"
- ".github/workflows/apple-release.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-checks.yml"
- ".github/workflows/release.yml"
- ".github/workflows/windows-store.yml"
- "packaging/android/**"
- "packaging/release/**"
- "packaging/version/**"
- "version.properties"
- "Makefile"
permissions:
contents: read
concurrency:
group: release-checks-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
scripts:
name: Validate release scripts
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run release checks
run: make check-release

459
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,459 @@
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
concurrency:
group: vnidrop-release
cancel-in-progress: false
jobs:
preflight:
name: Verify release tag
if: ${{ vars.RELEASE_PIPELINE_ENABLED == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
version: ${{ steps.version.outputs.app }}
android_code: ${{ steps.version.outputs.android_code }}
steps:
- name: Checkout release history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify canonical beta tag on current master
id: version
run: |
set -euo pipefail
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
channel="$(packaging/version/resolve-version.sh channel)"
master_sha="$(git rev-parse origin/master)"
if [ "$GITHUB_SHA" != "$master_sha" ]; then
echo "Release tags must point at the current master commit" >&2
exit 1
fi
if [ "$channel" != "beta" ]; then
echo "Only beta closed-testing releases are enabled" >&2
exit 1
fi
echo "app=$version" >> "$GITHUB_OUTPUT"
echo "android_code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
- name: Refuse an existing GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub Release $GITHUB_REF_NAME already exists" >&2
exit 1
fi
linux:
name: Linux packages
needs: preflight
uses: ./.github/workflows/linux-packages.yml
windows:
name: Windows Store package
needs: preflight
uses: ./.github/workflows/windows-store.yml
macos:
name: Signed and notarized macOS package
needs: preflight
uses: ./.github/workflows/apple-release.yml
secrets: inherit
android:
name: Signed Android package
needs: preflight
uses: ./.github/workflows/android-release.yml
secrets: inherit
play-closed-testing:
name: Stage Play closed-testing draft
needs:
- preflight
- linux
- windows
- macos
- android
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: play-closed-testing
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download signed Android artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-release
path: build/release/android
- name: Validate closed-testing configuration
env:
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
PLAY_SERVICE_ACCOUNT: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
run: |
for name in \
WORKLOAD_IDENTITY_PROVIDER \
PLAY_SERVICE_ACCOUNT \
PLAY_PACKAGE_NAME \
PLAY_CLOSED_TRACK \
PLAY_APP_SIGNING_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
echo "Missing Play closed-testing configuration: $name" >&2
exit 1
fi
done
case "${PLAY_CLOSED_TRACK,,}" in
production|*:production)
echo "Production Play tracks are forbidden" >&2
exit 1
;;
esac
if [ "$PLAY_PACKAGE_NAME" != "com.vnidrop.app" ]; then
echo "Unexpected Play package name: $PLAY_PACKAGE_NAME" >&2
exit 1
fi
- name: Authenticate to Google with GitHub OIDC
id: google-auth
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
token_format: access_token
access_token_scopes: https://www.googleapis.com/auth/androidpublisher
- name: Stage AAB and download Play-signed APK
env:
GOOGLE_PLAY_ACCESS_TOKEN: ${{ steps.google-auth.outputs.access_token }}
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
VERSION: ${{ needs.preflight.outputs.version }}
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
run: |
set -euo pipefail
shopt -s nullglob
bundles=(build/release/android/*.aab)
if [ "${#bundles[@]}" -ne 1 ]; then
echo "Expected exactly one signed AAB" >&2
exit 1
fi
mkdir -p build/release/play
python3 packaging/android/publish_play.py \
--bundle "${bundles[0]}" \
--package-name "$PLAY_PACKAGE_NAME" \
--track "$PLAY_CLOSED_TRACK" \
--version-code "$VERSION_CODE" \
--release-name "$VERSION" \
--expected-app-certificate "$PLAY_APP_SIGNING_CERT_SHA256" \
--apk-output "build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
--metadata-output build/release/play/play-release.json
- name: Set up Android SDK verification tools
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools build-tools;36.0.0"
- name: Verify Play-signed universal APK
env:
EXPECTED_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
VERSION: ${{ needs.preflight.outputs.version }}
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
run: |
set -euo pipefail
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
apkanalyzer_path="$(
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
sort -r |
head -1
)"
if [ -z "$apkanalyzer_path" ]; then
echo "apkanalyzer was not found" >&2
exit 1
fi
packaging/android/verify-apk-signature.sh \
"$apk" \
"$EXPECTED_CERT_SHA256" \
>/dev/null
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
echo "Play APK package name mismatch" >&2
exit 1
fi
if [ "$("$apkanalyzer_path" manifest version-name "$apk")" != "$VERSION" ]; then
echo "Play APK version name mismatch" >&2
exit 1
fi
if [ "$("$apkanalyzer_path" manifest version-code "$apk")" != "$VERSION_CODE" ]; then
echo "Play APK version code mismatch" >&2
exit 1
fi
(
cd build/release/play
sha256sum \
"VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
play-release.json \
> SHA256SUMS
)
- name: Upload Play-signed APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
path: build/release/play/
if-no-files-found: error
retention-days: 90
compression-level: 0
publish-microsoft-store:
name: Submit Microsoft Store update
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
runs-on: windows-2025
timeout-minutes: 30
environment: microsoft-store
permissions:
contents: read
steps:
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/windows
- name: Validate Microsoft Store configuration
id: store-package
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
$configuration = @{
AZURE_AD_TENANT_ID = $env:AZURE_AD_TENANT_ID
AZURE_AD_APPLICATION_CLIENT_ID = $env:AZURE_AD_APPLICATION_CLIENT_ID
AZURE_AD_APPLICATION_SECRET = $env:AZURE_AD_APPLICATION_SECRET
SELLER_ID = $env:SELLER_ID
MICROSOFT_STORE_PRODUCT_ID = $env:MICROSOFT_STORE_PRODUCT_ID
}
foreach ($entry in $configuration.GetEnumerator()) {
if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -eq "REPLACE_ME") {
throw "Missing Microsoft Store configuration: $($entry.Key)"
}
}
if ($env:MICROSOFT_STORE_PRODUCT_ID -ne "9NJ5Q0FG7TGL") {
throw "Unexpected Microsoft Store product ID: $env:MICROSOFT_STORE_PRODUCT_ID"
}
$packages = @(
Get-ChildItem build/release/windows -File -Filter *.msixupload -Recurse
)
if ($packages.Count -ne 1) {
throw "Expected exactly one msixupload package, found $($packages.Count)"
}
"path=$($packages[0].FullName)" >> $env:GITHUB_OUTPUT
- name: Set up Microsoft Store Developer CLI
uses: microsoft/microsoft-store-apppublisher@15abd1c50fcc164b19cb240fb04ef3c49bf715a2 # v1.1
with:
version: v0.3.9
- name: Authenticate and verify Store access
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
msstore reconfigure `
--tenantId "$env:AZURE_AD_TENANT_ID" `
--sellerId "$env:SELLER_ID" `
--clientId "$env:AZURE_AD_APPLICATION_CLIENT_ID" `
--clientSecret "$env:AZURE_AD_APPLICATION_SECRET"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store authentication failed"
}
msstore settings --enableTelemetry false
if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Microsoft Store CLI telemetry"
}
msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "The Microsoft Store application is not accessible"
}
- name: Publish package to Microsoft Store
shell: pwsh
env:
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
STORE_PACKAGE: ${{ steps.store-package.outputs.path }}
run: |
msstore publish "$env:STORE_PACKAGE" `
--appId "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store package publication failed"
}
- name: Summarize Store submission
shell: pwsh
env:
VERSION: ${{ needs.preflight.outputs.version }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
"### Microsoft Store submission" >> $env:GITHUB_STEP_SUMMARY
"- App version: $env:VERSION" >> $env:GITHUB_STEP_SUMMARY
"- Product ID: $env:MICROSOFT_STORE_PRODUCT_ID" >> $env:GITHUB_STEP_SUMMARY
"- Package submitted for certification" >> $env:GITHUB_STEP_SUMMARY
publish-github:
name: Publish coordinated GitHub Release
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
- publish-microsoft-store
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download Debian package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-deb-x64
path: build/release/downloads/deb
- name: Download RPM package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-rpm-x64
path: build/release/downloads/rpm
- name: Download macOS package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
path: build/release/downloads/macos
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/downloads/windows
- name: Download Play-signed APK
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
path: build/release/downloads/play
- name: Verify and assemble public release assets
run: packaging/release/assemble-release.sh
- name: Attest release provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: build/release/final/*
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
build/release/final/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "VniDrop ${{ needs.preflight.outputs.version }}" \
--generate-notes
update-homebrew:
name: Update Homebrew cask
needs:
- preflight
- macos
- publish-github
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download macOS package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
path: dist
- name: Render Homebrew cask
env:
VERSION: ${{ needs.preflight.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
- name: Push cask to tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.preflight.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 "Homebrew cask already matches ${VERSION}"
exit 0
}
git push

View File

@@ -6,6 +6,8 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile"
- "config.mk"
- "make/**"
@@ -19,6 +21,8 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile"
- "config.mk"
- "make/**"

View File

@@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "Cargo.toml"
- "Cargo.lock"
@@ -24,6 +26,8 @@ on:
- master
paths:
- "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "Cargo.toml"
- "Cargo.lock"

View File

@@ -5,6 +5,8 @@ on:
paths:
- ".github/workflows/windows-store.yml"
- "packaging/windows/**"
- "packaging/version/**"
- "version.properties"
- "assets/windows/**"
- "desktopApp/**"
- "shared/**"
@@ -17,16 +19,8 @@ on:
- "gradle/**"
- "gradlew"
- "gradlew.bat"
push:
tags:
- "v*.*.*"
workflow_call:
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions:
contents: read
@@ -77,37 +71,13 @@ jobs:
restore-keys: |
windows-x64-cargo-1.91.0-
- name: Resolve Store version
- name: Resolve canonical version
id: version
shell: pwsh
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
$version = $env:REQUESTED_VERSION
if ($env:GITHUB_REF_TYPE -eq "tag") {
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
throw "Store release tags must use vMAJOR.MINOR.PATCH"
}
$version = $env:GITHUB_REF_NAME.Substring(1)
}
if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
throw "Version must use MAJOR.MINOR.PATCH"
}
$parts = $version.Split(".")
for ($index = 0; $index -lt $parts.Count; $index++) {
$part = $parts[$index]
$number = 0
if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
throw "Version components must be canonical integers"
}
if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
throw "Version components must be between 0 and 65535, with a non-zero major"
}
}
"app=$version" >> $env:GITHUB_OUTPUT
"package=$version.0" >> $env:GITHUB_OUTPUT
$version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
"app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
"package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
- name: Test and build release app image
shell: pwsh
@@ -115,7 +85,6 @@ jobs:
$arguments = @(
":shared:jvmTest"
":desktopApp:createReleaseDistributable"
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
"-Pvnidrop.desktop.rustVariant=release"
"-Pvnidrop.diagnostics.included=false"
"--no-daemon"
@@ -131,7 +100,6 @@ jobs:
shell: pwsh
run: |
$arguments = @{
Version = "${{ steps.version.outputs.app }}"
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
OutputDirectory = ".\build\release\windows"
}

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ config.override.mk
# Local design export scratch
output/
.screenshots
apple/RELEASE-MACOS.md
apple/Generated/*.xcconfig

View File

@@ -48,6 +48,15 @@ Domain docs (reference, do not paste into PRs):
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)
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.
---
@@ -291,6 +300,8 @@ branch from updated `master`.
- Flaky multi-minute sleeps in tests
- Unsigned commits when signing is required
- 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

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

View File

@@ -12,14 +12,14 @@ include $(ROOT)/make/release.mk
.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: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
.PHONY: prepare-release check-version check-release 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' 'version.properties' 'Canonical application version ($(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'
@@ -59,7 +59,26 @@ format: ## Format Rust sources.
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: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
prepare-release: ## Update PRODUCT_VERSION and show its derived store versions (RELEASE_VERSION=x.y.z).
@test -n "$(RELEASE_VERSION)" || { printf 'Usage: make prepare-release RELEASE_VERSION=x.y.z\n' >&2; exit 1; }
cd $(ROOT) && packaging/version/prepare-release.sh "$(RELEASE_VERSION)"
check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && packaging/version/test-version.sh
cd $(ROOT) && packaging/version/resolve-version.sh verify
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-release: ## Validate coordinated release scripts and workflow YAML.
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
cd $(ROOT) && packaging/release/test-assemble-release.sh
cd $(ROOT) && packaging/release/test-release-config.sh
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check
@@ -113,7 +132,10 @@ 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 ## Generate the native Apple Xcode project.
apple-version-config: ## Generate derived Store and Direct Apple build settings.
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project.
cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
@@ -122,6 +144,15 @@ open-apple-project: apple-project ## Generate and open the native Apple Xcode pr
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: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
cd $(ROOT) && apple/scripts/package-core.sh
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"

View File

@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
}
check(missing.isEmpty()) {
"Debug APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
"APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
}
}
}
@@ -37,6 +37,22 @@ plugins {
alias(libs.plugins.composeCompiler)
}
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int
val releaseKeystorePath = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PATH").orNull
val releaseKeystorePassword = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("VNIDROP_ANDROID_KEY_ALIAS").orNull
val releaseKeyPassword = providers.environmentVariable("VNIDROP_ANDROID_KEY_PASSWORD").orNull
val releaseSigningValues = listOf(
releaseKeystorePath,
releaseKeystorePassword,
releaseKeyAlias,
releaseKeyPassword,
)
require(releaseSigningValues.all { it == null } || releaseSigningValues.all { it != null }) {
"Android release signing requires the keystore path, keystore password, key alias, and key password together"
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
@@ -55,12 +71,26 @@ android {
namespace = "com.vnidrop.app"
compileSdk = libs.versions.android.compileSdk.get().toInt()
signingConfigs {
if (releaseKeystorePath != null) {
create("release") {
val keystoreFile = rootProject.file(releaseKeystorePath)
.also { require(it.isFile) { "Android release keystore was not found" } }
.also { require(it.canRead()) { "Android release keystore is not readable" } }
storeFile = keystoreFile
storePassword = releaseKeystorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
defaultConfig {
applicationId = "com.vnidrop.app"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
versionCode = androidVersionCode
versionName = appVersion
}
packaging {
resources {
@@ -76,6 +106,7 @@ android {
buildTypes {
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
@@ -87,6 +118,10 @@ android {
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug"))
}
getByName("release") {
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/release"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/release"))
}
}
}
@@ -97,6 +132,12 @@ tasks.configureEach {
":shared:copyAndroidAndroidX64Debug",
)
}
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
dependsOn(
":shared:copyAndroidAndroidArm64Release",
":shared:copyAndroidAndroidX64Release",
)
}
}
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
@@ -38,7 +40,7 @@
<data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>

8
apple/.gitignore vendored
View File

@@ -3,6 +3,10 @@
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/
@@ -14,3 +18,7 @@ Local.xcconfig
.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

View File

@@ -34,12 +34,36 @@ Prerequisites: Xcode, Rust with the Apple targets
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
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 build
make build-apple-ios # unsigned iOS simulator app
make check-apple # iOS simulator tests
```
`make apple-project` also generates ignored Store and Direct version xcconfig
files. Their `CURRENT_PROJECT_VERSION` values come from the central version
resolver as UTC `YYYYMMDD.HHMM.SS` build identifiers. Regenerate the project
before creating another App Store archive so it receives a fresh build number;
direct DMG builds refresh their own value automatically.
### macOS shipping channels
The macOS app ships through two targets that build identical sources:
- **`VniDrop`** (`Release`) — Mac App Store / TestFlight. Sandboxed, no
self-updater.
- **`VniDropDirect`** (`Release-Direct`) — direct-download `.dmg` on GitHub
Releases + Homebrew cask. Adds the **Sparkle** auto-updater behind the
`DIRECT_DISTRIBUTION` compile flag, so the App Store binary never links Sparkle.
```bash
make build-apple-macos-direct # unsigned compile-check of the direct target
make build-apple-dmg # signed (+ notarized) .dmg
```
Full signing, notarization, appcast, and cask flow: see
[`RELEASE-MACOS.md`](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

View File

@@ -19,7 +19,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
XCTAssertEqual(repo.preferences.username, "Default")
XCTAssertEqual(repo.preferences.themeMode, .system)
XCTAssertFalse(repo.preferences.notificationsEnabled)
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
}
@@ -29,7 +28,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
repo.setUsername("Bob")
repo.setThemeMode(.dark)
repo.setNotificationsEnabled(true)
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
repo.setRelayConfiguration(RelayConfiguration(
mode: .strictCustom,
@@ -40,7 +38,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
XCTAssertEqual(reloaded.preferences.username, "Bob")
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(

View File

@@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase {
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
ApprovalCoordinator(
repository: core,
preferences: Fixtures.preferences(),
notifications: LocalNotificationService(),
visibility: AppVisibility(),
messages: UiMessageController()

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

@@ -40,7 +40,7 @@ final class ProgressDerivationTests: XCTestCase {
event(phase: "import", kind: "started", json: "{}"),
]
let progress = progressForTransfer(events: events, transferId: 1)
XCTAssertEqual(progress?.labelKey, "progress_preparing")
XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
XCTAssertEqual(progress?.progress, 0.3)
}
@@ -57,15 +57,15 @@ final class ProgressDerivationTests: XCTestCase {
remoteEndpointId: "peer-a",
totalSizeHint: 100
)
XCTAssertEqual(progress?.kind, "completed")
XCTAssertEqual(progress?.labelKey, "progress_completed")
XCTAssertEqual(progress?.kind, .completed)
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
XCTAssertEqual(progress?.progress, 1)
}
func testStatusLabelKeys() {
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
XCTAssertEqual(statusLabelKey(.done), "status_completed")
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 {

View File

@@ -24,6 +24,9 @@ final class ReceiveModelTests: XCTestCase {
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)

View File

@@ -32,6 +32,9 @@ final class SendModelTests: XCTestCase {
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)

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

@@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase {
func testErrorSuppressesUserCancellation() {
let c = UiMessageController()
c.error(InvitationError.message("QR scanning was cancelled"))
c.error(InvitationError.cancelled)
XCTAssertNil(c.current) // cancellations are swallowed
}
func testErrorShowsNonCancellation() {
let c = UiMessageController()
c.error(InvitationError.message("The transfer was refused"))
c.error(InvitationError.raw("The transfer was refused"))
XCTAssertEqual(c.current?.tone, .error)
}
}
@@ -36,28 +36,31 @@ final class UiMessageControllerTests: XCTestCase {
final class UserFacingErrorTests: XCTestCase {
func testIsUserCancellation() {
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation)
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation)
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
}
func testToUiTextMapsKnownReasons() {
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission"))
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket"))
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty"))
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera"))
// 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.message("something entirely unexpected").toUiText(), .resource("error_generic"))
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
}
func testToUiTextMapsTypedTransferFailures() {
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem"))
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists"))
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full"))
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network"))
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input"))
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

@@ -12,6 +12,8 @@ final class AppGraph: ObservableObject {
let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies
@@ -23,17 +25,22 @@ final class AppGraph: ObservableObject {
username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system,
notificationsEnabled: false,
diagnosticsEnabled: false
)
)
self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository,
preferences: preferencesRepository,
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])
}

View File

@@ -1,3 +1,4 @@
import SFSafeSymbols
import SwiftUI
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
@@ -8,8 +9,6 @@ struct RootView: View {
@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
@@ -45,8 +44,6 @@ struct RootView: View {
messages: graph.messages,
bugReports: NoopBugReportService()
))
messages = graph.messages
approvals = graph.approvalCoordinator
}
var body: some View {
@@ -55,13 +52,26 @@ struct RootView: View {
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
SnackbarHost(controller: messages)
ApprovalModalHost(
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
// Observe the coordinator/messages from the *persisted* `graph`
// StateObject. Deriving them in `init` bound the view to a throwaway
// AppGraph rebuilt on every re-init, whose coordinator never receives
// core events so the approval modal never appeared.
ApprovalLayer(
approvals: graph.approvalCoordinator,
sendModel: sendModel
)
// Top-most so the toast is never covered by the approval overlay's
// full-bleed clear layer. Observes the live `graph.messages` directly.
SnackbarHost(controller: graph.messages)
}
.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)
@@ -72,23 +82,23 @@ struct RootView: View {
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, .inactive:
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) so the approval sheet isn't presented under it
// on macOS.
.onChange(of: approvals.state.current?.id) { _, id in
if id != nil { sendModel.closeDetailPanel() }
}
#if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so
// drive foreground/background off NSApplication's active state instead
@@ -111,7 +121,7 @@ struct RootView: View {
#if os(macOS)
NavigationSplitView {
List(AppDestination.allCases, selection: sidebarBinding) { destination in
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
.tag(destination)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
@@ -123,7 +133,7 @@ struct RootView: View {
ForEach(AppDestination.allCases) { destination in
screen(for: destination, windowClass: windowClass)
.tabItem {
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
}
.tag(destination)
}
@@ -182,6 +192,92 @@ struct RootView: View {
}
}
/// Hosts the approval modal, observing the coordinator passed in from the persisted
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
/// established here (in `body`) against the live instance, rather than in
/// `RootView.init` against a throwaway graph.
private struct ApprovalLayer: View {
@ObservedObject var approvals: ApprovalCoordinator
let sendModel: SendModel
/// 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
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
/// that sheet and present the approval once its dismissal completes (see
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
@State private var approvalAwaitingSheetDismiss = false
var body: some View {
ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
)
// A pending approval is a blocking modal. Close any open share/QR sheet first
// (the detail-view panel *or* the list-level share sheet), then present the
// approval sheet: the approval is presented from the app root and neither
// platform reliably stacks it over a sheet owned by the Send screen.
.onChange(of: approvals.state.current?.id) { _, id in
guard id != nil else {
showApproval = false
approvalAwaitingSheetDismiss = false
return
}
let wasShowingSheet = sendModel.state.detailPanel != nil
|| sendModel.state.shareTargetId != nil
sendModel.dismissShareSheets()
#if os(macOS)
// macOS silently drops a sheet presented while another is still dismissing,
// so wait for that sheet's real dismissal completion before presenting.
if wasShowingSheet {
approvalAwaitingSheetDismiss = true
} else {
showApproval = true
}
#else
_ = wasShowingSheet
showApproval = true
#endif
}
#if os(macOS)
.onReceive(sendModel.shareSheetsDismissed) { _ in
guard approvalAwaitingSheetDismiss else { return }
approvalAwaitingSheetDismiss = false
if approvals.state.current != nil { showApproval = true }
}
#endif
}
}
/// 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

View File

@@ -1,17 +1,39 @@
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 {
WindowGroup {
#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

View File

@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
var username: String
var receiveFolder: ReceiveFolder
var themeMode: ThemeMode
var notificationsEnabled: Bool
var diagnosticsEnabled: Bool
var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
@@ -130,7 +129,6 @@ struct AppPreferencesDefaults {
let username: String
let receiveFolder: ReceiveFolder
let themeMode: ThemeMode
var notificationsEnabled: Bool = false
var diagnosticsEnabled: Bool = false
}
@@ -147,7 +145,6 @@ final class AppPreferencesRepository: ObservableObject {
static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode"
static let notificationsEnabled = "notifications_enabled"
static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration"
@@ -163,14 +160,12 @@ final class AppPreferencesRepository: ObservableObject {
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 notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
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,
notificationsEnabled: notifications,
diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults)
@@ -224,11 +219,6 @@ final class AppPreferencesRepository: ObservableObject {
reload()
}
func setNotificationsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.notificationsEnabled)
reload()
}
func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload()

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

@@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
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 {
@@ -88,6 +134,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
case refused
case expired
case completed
case failed
case unknown
}

View File

@@ -80,7 +80,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
// 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?
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
// 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
@@ -154,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
guard !sources.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
return .failure(InvitationError.shareEmpty)
}
return await runCore {
let result = try self.requireCore().shareFiles(
@@ -222,7 +224,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
// MARK: - Lifecycle actions
func cancel(transferId: UInt64) async -> Result<Void, Error> {
await runCore {
// 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() }
}
@@ -349,24 +353,21 @@ final class CoreRepository: ObservableObject, CoreGateway {
private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else {
throw InvitationError.message("Initialize the core first.")
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 withCheckedContinuation { continuation in
queue.async {
let result: Result<T, Error>
do {
result = .success(try block())
} catch {
result = .failure(error)
}
continuation.resume(returning: result)
}
}
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 {
@@ -401,14 +402,15 @@ private extension CoreEvent {
}
}
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
private let refreshKinds: Set<String> = [
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
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 {
refreshPhases.contains(phase) && refreshKinds.contains(kind)
guard let eventPhase, let eventKind else { return false }
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
}
}
@@ -512,6 +514,7 @@ private extension ReceiverRequest {
case "refused": return .refused
case "expired": return .expired
case "completed": return .completed
case "failed": return .failed
default: return .unknown
}
}

View File

@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
}
func reportOpenFailure(message: String) {
continuation?.yield(.failure(InvitationError.message(message)))
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 message(String)
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? {
switch self {
case .empty: return "The invitation is empty"
case .tooLarge: return "The invitation is too large"
case .invalidEncoding: return "The invitation is not valid text"
case .message(let m): return m
}
if case .raw(let reason) = self { return reason }
return String(describing: self)
}
}

View File

@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
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 }
}
@@ -48,7 +52,7 @@ extension FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
.failure(InvitationError.message("Revealing the receive folder is not supported"))
.failure(InvitationError.unsupportedOperation)
}
func discardPickedFiles(_ files: [PickedShareFile]) async {}

View File

@@ -16,12 +16,63 @@ struct LocalNotification {
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.
///
/// `@MainActor` is required, not just convenient: these delegate methods are
/// `async`, so their continuation resumes at the return point on whatever executor
/// they ran on. When the system hands a notification-tap back to UIKit it performs
/// state-restoration/snapshot work synchronously on that thread which asserts
/// "Call must be made on main thread" and crashes if the method returned off-main.
/// Main-actor isolation guarantees the return happens on the main thread.
// `@preconcurrency` on the conformance: these delegate requirements are nonisolated
// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a
// main actor-isolated type witness. The main-actor isolation is what fixes the
// crash (see the type doc above); the attribute inserts the runtime hop.
@MainActor
private final class NotificationPresenter: NSObject, @preconcurrency 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)
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()
@@ -75,11 +126,6 @@ final class LocalNotificationService: ObservableObject {
center.removeDeliveredNotifications(withIdentifiers: [id])
}
func cancelAll() {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
switch status {
case .authorized, .provisional, .ephemeral: return .granted

View File

@@ -18,9 +18,9 @@ func windowClassFor(width: Double) -> WindowClass {
/// resolved at the view layer.
struct TransferProgress: Equatable {
let transferId: UInt64?
let phase: String
let kind: String
let labelKey: String
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",
@@ -28,44 +28,31 @@ struct TransferProgress: Equatable {
var label: String? = nil
}
func statusLabelKey(_ status: TransferStatus) -> String {
func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
switch status {
case .importing: return "status_preparing"
case .sharing: return "status_available"
case .receiving: return "status_receiving"
case .done: return "status_completed"
case .cancelled: return "status_cancelled"
case .stopped: return "status_stopped"
case .failed: return "status_failed"
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
}
}
private let progressPhases: Set<String> = [
"import", "ticket", "access", "transfer", "download", "export",
"lifecycle", "network", "handshake", "error",
]
private let progressKinds: Set<String> = [
"started", "copy-progress", "copy-done", "outboard-progress", "done",
"created", "progress", "completed", "aborted", "failed",
"connecting", "connected", "found-collection",
"cancelled", "share-stopped",
]
/// Latest progress snapshot for a transfer. Events are newest-first.
/// 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
&& progressPhases.contains(event.phase)
&& progressKinds.contains(event.kind)
event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
}
guard let latest = relevant.first else { return 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: latest.phase,
kind: latest.kind,
labelKey: humanProgressLabel(latest),
phase: phase,
kind: kind,
labelKey: humanProgressLabel(phase: phase, kind: kind),
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
detail: progressDetail(latest)
)
@@ -80,33 +67,34 @@ func progressForReceiver(
) -> 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.direction == "send"
&& event.phase == "transfer"
&& ["started", "progress", "completed", "aborted"].contains(event.kind)
&& event.eventDirection == .send
&& event.eventPhase == .transfer
&& (event.eventKind.map(receiverKinds.contains) ?? false)
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
}
if transferEvents.isEmpty { return nil }
let latest = transferEvents[0]
if latest.kind == "aborted" {
guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
if latestKind == .aborted {
return TransferProgress(
transferId: transferId, phase: "transfer", kind: "aborted",
labelKey: "progress_interrupted", progress: nil, detail: nil
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)
if latest.kind == "completed" && (progress.map { $0 >= 0.999 } ?? true) {
return TransferProgress(
transferId: transferId, phase: "transfer", kind: "completed",
labelKey: "progress_completed", progress: 1, detail: nil
)
}
return TransferProgress(
transferId: transferId, phase: "transfer", kind: latest.kind,
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
transferId: transferId, phase: .transfer, kind: latestKind,
labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest)
)
}
@@ -127,26 +115,26 @@ func formatBytes(_ size: UInt64) -> String {
// MARK: - Internals (ported literally from AppUiModels.kt)
private func humanProgressLabel(_ event: CoreEventModel) -> String {
switch (event.phase, event.kind) {
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"):
return "progress_preparing"
case ("import", "done"): return "progress_ready"
case ("ticket", "created"): return "progress_share_ready"
case ("network", "connecting"): return "progress_connecting"
case ("network", "connected"): return "progress_connected"
case ("download", "found-collection"): return "progress_getting_ready"
case ("download", "progress"): return "progress_downloading"
case ("export", "progress"): return "progress_saving"
case ("transfer", "progress"): return "progress_sending"
case ("transfer", "started"): return "progress_connected"
case ("transfer", "completed"): return "progress_completed"
case ("lifecycle", "done"): return "progress_completed"
case ("lifecycle", "cancelled"): return "progress_cancelled"
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 event.phase == "handshake" { return "progress_requesting_access" }
if event.kind == "failed" { return "progress_failed" }
return "progress_working"
if phase == .handshake { return L10n.Progress.requestingAccess }
if kind == .failed { return L10n.Progress.failed }
return L10n.Progress.working
}
}
@@ -217,14 +205,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint:
order.append(requestKey)
}
if let size, size > 0 { state.size = size }
switch event.kind {
case "progress", "started":
switch event.eventKind {
case .progress, .started:
if let endOffset { state.offset = max(state.offset, endOffset) }
state.aborted = false
case "completed":
case .completed:
state.completed = true
if let s = state.size { state.offset = s }
case "aborted":
case .aborted:
state.aborted = true
default:
break

View File

@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
@Published private(set) var state = ApprovalState()
private let repository: CoreGateway
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
init(
repository: CoreGateway,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.preferences = preferences
self.notifications = notifications
self.visibility = visibility
self.messages = messages
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
.store(in: &cancellables)
// Recompute notifications when any input changes.
Publishers.CombineLatest4(
preferences.$preferences,
Publishers.CombineLatest3(
visibility.$isForeground,
$state,
notifications.$permission
)
.sink { [weak self] preferences, foreground, approvalState, permission in
.sink { [weak self] foreground, approvalState, permission in
guard let self else { return }
Task {
await self.synchronizeNotifications(
enabled: preferences.notificationsEnabled,
foreground: foreground,
pending: approvalState.pending,
permission: permission
@@ -137,30 +132,41 @@ final class ApprovalCoordinator: ObservableObject {
}
private func synchronizeNotifications(
enabled: Bool,
foreground: Bool,
pending: [PendingApproval],
permission: NotificationPermission
) async {
if foreground || !enabled || permission != .granted {
notifications.cancelAll()
// 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: "approval_nearby_device")
let title = String(localized: "approval_connection_request")
let body = String(
format: String(localized: "approval_request_body"),
receiver, request.transferName
)
?? 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)
)
switch result {
case .success: publishedNotificationIds.insert(request.id)
case .failure(let error): messages.error(error)
if case .failure(let error) = result {
publishedNotificationIds.remove(request.id)
messages.error(error)
}
}
}

View File

@@ -1,16 +1,21 @@
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: .constant(state.current != nil)) {
.sheet(isPresented: $isPresented) {
if let request = state.current {
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
.interactiveDismissDisabled(true)
@@ -38,32 +43,32 @@ private struct ApprovalSheet: View {
var body: some View {
let busy = state.respondingIds.contains(request.id)
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device")
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: L10n.Approval.nearbyDevice)
VStack(spacing: 16) {
Image(systemName: "checkmark.shield.fill")
Image(systemSymbol: .checkmarkShieldFill)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(LocalizedStringKey("approval_connection_request"))
Text(String(localized: L10n.Approval.connectionRequest))
.font(.title2).fontWeight(.semibold)
Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName))
Text(L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName))
.multilineTextAlignment(.center)
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId))
Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId))
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if state.pending.count > 1 {
Text(String(format: String(localized: "approval_pending_count"), state.pending.count))
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(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity)
Text(String(localized: L10n.Button.approve)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
Button(role: .destructive, action: { onRefuse(request.id) }) {
Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity)
Text(String(localized: L10n.Button.refuse)).frame(maxWidth: .infinity)
}
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
}

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

@@ -1,8 +1,9 @@
import SFSafeSymbols
import SwiftUI
enum ReceiveMethodAvailability { case available, unavailable, hidden }
/// Invitation acquisition actions shared by the native Apple feature models.
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
@MainActor
protocol ReceiveInvitationActions: AnyObject {
var fileAvailability: ReceiveMethodAvailability { get }
@@ -23,25 +24,25 @@ struct ReceiveMethodPanel: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
MethodRow(
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
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: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
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: "wave.3.right",
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
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)
@@ -56,10 +57,10 @@ struct ReceiveMethodPanel: View {
private struct MethodRow: View {
@Environment(\.vniColors) private var colors
let icon: String
let icon: SFSymbol
var titleOverride: String? = nil
let titleKey: String
let descKey: String
let titleKey: String.LocalizationValue
let descKey: String.LocalizationValue
let availability: ReceiveMethodAvailability
let onTap: () -> Void
@@ -67,20 +68,20 @@ private struct MethodRow: View {
let enabled = availability == .available
Button(action: onTap) {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 22))
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(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
Text(String(localized: titleKey)).font(VniType.bodyLarge)
}
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
Spacer()
if availability == .unavailable {
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
}
}
.padding(16)
@@ -102,7 +103,7 @@ struct InvitationReviewPanel: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge)
if state.isInspecting {
ProgressView().frame(maxWidth: .infinity).padding(40)
}
@@ -110,28 +111,31 @@ struct InvitationReviewPanel: View {
let metadata = inspection.metadata
VStack(alignment: .leading, spacing: 8) {
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
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: "field_receiver_name"),
Field(label: String(localized: L10n.Field.receiverName),
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
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.direction == "receive" && $0.transferId != nil }?.transferId
?? 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 ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
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: "button_receive"), action: model.receive,
title: String(localized: L10n.Button.receive), action: model.receive,
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
)
}

View File

@@ -121,6 +121,10 @@ final class ReceiveModel: ObservableObject {
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 {
@@ -133,7 +137,7 @@ final class ReceiveModel: ObservableObject {
case .success:
state.historyDeleteTarget = nil
state.isDeletingHistory = false
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
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
@@ -173,9 +177,9 @@ final class ReceiveModel: ObservableObject {
resetAcquisition()
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(UiMessage(
text: .resource("receive_completed"),
text: .resource(L10n.Receive.completed),
tone: .success,
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
))
case .failure(let error):
@@ -192,8 +196,8 @@ final class ReceiveModel: ObservableObject {
messages.tryShow(UiMessage(
text: uiText,
tone: .error,
actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil,
onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil
actionLabel: .resource(L10n.Button.retry),
onAction: { self.receive() }
))
}
}
@@ -202,7 +206,7 @@ final class ReceiveModel: ObservableObject {
func cancelActiveReceive() {
let transferId = state.activeReceiveTransferId
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
guard let transferId else { return }
Task {
let result = await repository.cancel(transferId: transferId)
@@ -222,14 +226,14 @@ final class ReceiveModel: ObservableObject {
Task {
let result = await fileSystemService.revealReceiveFolder(folder)
if case .failure = result {
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
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("error_invitation_empty")) }
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
state.isAcquisitionOpen = true
state.ticket = ticket
state.method = method

View File

@@ -1,4 +1,5 @@
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.
@@ -22,17 +23,17 @@ struct ReceiveScreen: View {
history
}
}
.navigationTitle(Text(LocalizedStringKey("receive_title")))
.navigationTitle(Text(String(localized: L10n.Receive.title)))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus")
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
}
}
if !deletable.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestClearHistory) {
Label(String(localized: "receive_clear_history"), systemImage: "trash")
Label(String(localized: L10n.Receive.clearHistory), systemSymbol: .trash)
}
}
}
@@ -50,11 +51,11 @@ struct ReceiveScreen: View {
}
}
.alert(
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
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: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
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
@@ -74,27 +75,36 @@ struct ReceiveScreen: View {
Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId)
} label: {
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
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(LocalizedStringKey("receive_history_title"))
Text(String(localized: L10n.Receive.historyTitle))
} footer: {
Text(LocalizedStringKey("receive_new_subtitle"))
Text(String(localized: L10n.Receive.newSubtitle))
}
}
}
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown)
} description: {
Text(LocalizedStringKey("receive_empty_body"))
Text(String(localized: L10n.Receive.emptyBody))
} actions: {
Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus")
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
@@ -107,10 +117,10 @@ struct ReceiveScreen: View {
private var historyDeleteMessage: some View {
if let target = model.state.historyDeleteTarget {
if target == .all {
Text(LocalizedStringKey("receive_clear_history_description"))
Text(String(localized: L10n.Receive.clearHistoryDescription))
} else {
Text(String(format: String(localized: "receive_delete_history_description"),
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
Text(L10n.Receive.deleteHistoryDescription(
transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
}
}
}
@@ -129,14 +139,14 @@ private struct ReceiveTransferRow: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: "doc")
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: "receive_unknown_transfer"))
Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer))
.font(.body).lineLimit(1)
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
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)

View File

@@ -25,6 +25,11 @@ struct SendState: Equatable {
var selectedTransferId: UInt64?
var transferThumbnails: [UInt64: Data] = [:]
var detailPanel: TransferDetailPanel?
/// Transfer whose share panel is presented inline from the list context menu
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
/// Held in the model not `SendScreen` @State so the approval flow can dismiss
/// it centrally before presenting its modal.
var shareTargetId: UInt64?
var receiverHistory: [ReceiverRequestModel] = []
var isLoadingReceivers = false
var isDeleteConfirmationOpen = false
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
/// sheet) has finished animating out. The approval flow waits on this to present
/// its modal on macOS, where a sheet shown while another is still dismissing is
/// dropped using the real completion instead of a guessed delay.
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
var shareSheetsDismissed: AnyPublisher<Void, Never> {
shareSheetsDismissedSubject.eraseToAnyPublisher()
}
/// Invoked by a share sheet's `onDismiss` completion.
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
init(
repository: CoreGateway,
fileSystemService: FileSystemService,
@@ -154,7 +171,7 @@ final class SendModel: ObservableObject {
}
func onFilePickFailed(_ reason: String) {
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
}
func clearSelectedSource() {
@@ -201,12 +218,26 @@ final class SendModel: ObservableObject {
}
func closeDetailPanel() { state.detailPanel = nil }
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
func closeShareTarget() { state.shareTargetId = nil }
/// Dismisses every share/QR surface at once the detail-view share panel and the
/// list-level share sheet. Used before presenting the receiver-approval modal, so
/// no competing sheet is left open (macOS drops a sheet shown over another).
func dismissShareSheets() {
state.detailPanel = nil
state.shareTargetId = 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 {
@@ -217,7 +248,32 @@ final class SendModel: ObservableObject {
state.receiverHistory = []
state.isDeleteConfirmationOpen = false
state.isDeleting = false
messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success))
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)
@@ -229,8 +285,19 @@ final class SendModel: ObservableObject {
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
/// receivers that are still pending or accepted.
func cancelReceiver(requestId: String) {
respondToReceiver(requestId: requestId, accepted: false)
}
/// Approves a single pending receiver by responding to its request positively.
/// A fallback for when the approval modal didn't surface the pending receiver
/// can still be accepted from its row in the transfer's receivers panel.
func acceptReceiver(requestId: String) {
respondToReceiver(requestId: requestId, accepted: true)
}
private func respondToReceiver(requestId: String, accepted: Bool) {
Task {
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
switch result {
case .success:
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
@@ -249,7 +316,7 @@ final class SendModel: ObservableObject {
switch result {
case .success:
_ = await repository.refresh()
messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info))
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info))
case .failure(let error):
messages.error(error)
}
@@ -261,10 +328,10 @@ final class SendModel: ObservableObject {
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
switch result {
case .success:
let key: String?
let key: String.LocalizationValue?
switch action {
case .export: key = "transfer_invitation_saved"
case .nfc: key = "transfer_nfc_written"
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)) }
@@ -297,7 +364,14 @@ final class SendModel: ObservableObject {
state.transferName = ""
state.accessPolicy = .requireApproval
state.isSharing = false
messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success))
// 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)

View File

@@ -1,4 +1,5 @@
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.
@@ -6,9 +7,18 @@ struct SendScreen: View {
@ObservedObject var model: SendModel
let windowClass: WindowClass
/// Transfer pending an inline (list-level) delete confirmation.
@State private var deleteTarget: Transfer?
private var outgoing: [Transfer] {
model.coreState.transfers.filter { $0.direction == .send }
}
/// The transfer whose list-level share sheet is open, resolved from the model's
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
private var shareTarget: Transfer? {
guard let id = model.state.shareTargetId else { return nil }
return outgoing.first { $0.transferId == id }
}
private var selectedTransfer: Transfer? {
guard let id = model.state.selectedTransferId else { return nil }
return outgoing.first { $0.transferId == id }
@@ -27,11 +37,11 @@ struct SendScreen: View {
catalog
}
}
.navigationTitle(Text(LocalizedStringKey("send_title")))
.navigationTitle(Text(String(localized: L10n.Send.title)))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openComposer) {
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
}
}
}
@@ -40,6 +50,19 @@ struct SendScreen: View {
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 { model.closeShareTarget() } }),
windowClass: windowClass,
onDismiss: model.closeShareTarget,
onDismissed: model.shareSheetDidDismiss
) {
if let shareTarget {
TransferSharePanel(model: model, transfer: shareTarget)
}
}
}
.adaptiveDrawer(
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
@@ -48,8 +71,24 @@ struct SendScreen: View {
) {
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).
@@ -58,21 +97,22 @@ struct SendScreen: View {
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.closeDetailPanel
onDismiss: model.closeDetailPanel,
onDismissed: model.shareSheetDidDismiss
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel)
}
}
.alert(
Text(LocalizedStringKey("transfer_delete_title")),
Text(String(localized: L10n.Transfer.deleteTitle)),
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
) {
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer)
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer)
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
} message: {
Text(String(format: String(localized: "transfer_delete_description"),
transfer.transferName ?? String(localized: "send_new_transfer_title")))
Text(L10n.Transfer.deleteDescription(
transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
}
}
@@ -90,23 +130,45 @@ struct SendScreen: View {
)
}
.buttonStyle(.plain)
.contextMenu {
if transfer.ticket != nil {
Button {
model.openShareTarget(transfer.transferId)
} 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(LocalizedStringKey("send_transfers_title"))
Text(String(localized: L10n.Send.transfersTitle))
} footer: {
Text(LocalizedStringKey("send_subtitle"))
Text(String(localized: L10n.Send.subtitle))
}
}
}
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: "send_empty_title"), systemImage: "paperplane")
Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
} description: {
Text(LocalizedStringKey("send_empty_body"))
Text(String(localized: L10n.Send.emptyBody))
} actions: {
Button(action: model.openComposer) {
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
@@ -127,21 +189,18 @@ struct SendScreen: View {
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
if active.isEmpty { return nil }
let fractions: [Double] = active.compactMap { receiver -> Double? in
let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize)
guard progress?.kind == "started" || progress?.kind == "progress" else { return nil }
return progress?.progress
let fractions = active.compactMap {
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
}
guard !fractions.isEmpty else { return nil }
let combined = fractions.reduce(0, +) / Double(fractions.count)
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: "progress_sending", progress: combined)
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: "progress_sending", progress: combined,
label: String(format: String(localized: "progress_sending_to_count"), active.count))
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
labelKey: L10n.Progress.sending, progress: combined,
label: L10n.Progress.sendingToCount(count: active.count))
}
}
@@ -157,19 +216,19 @@ private struct TransferListItem: View {
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
.font(.body).lineLimit(1)
Spacer()
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
}
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
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(systemName: "chevron.forward")
Image(systemSymbol: .chevronForward)
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
@@ -184,7 +243,7 @@ struct FileArtwork: View {
image.resizable().aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Image(systemName: "doc")
Image(systemSymbol: .doc)
.font(.system(size: 18))
.foregroundStyle(.secondary)
}
@@ -192,13 +251,13 @@ struct FileArtwork: View {
}
func statusLabel(_ status: TransferStatus) -> String {
String(localized: String.LocalizationValue(statusLabelKey(status)))
String(localized: statusLabelKey(status))
}
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
switch policy {
case .requireApproval: return String(localized: "send_access_approval")
case .anyoneWithTransfer: return String(localized: "send_access_anyone")
case .requireApproval: return String(localized: L10n.Send.accessApproval)
case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import SFSafeSymbols
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
/// Two steps: choose files/folder, then review + name + access policy + share.
@@ -23,13 +24,13 @@ struct TransferComposer: View {
private var chooseStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold)
Text(LocalizedStringKey("send_choose_file_body"))
Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold)
Text(String(localized: L10n.Send.chooseFileBody))
.font(.subheadline).foregroundStyle(.secondary)
VStack(spacing: 14) {
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint)
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize()
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder)
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)
@@ -39,9 +40,9 @@ struct TransferComposer: View {
private var reviewStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold)
Text(String(localized: L10n.Send.reviewTitle)).font(.title2).fontWeight(.semibold)
if state.selectedFiles.count > 1 {
Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count))
Text(L10n.Send.selectedFilesCount(count: state.selectedFiles.count))
.font(.subheadline).foregroundStyle(.secondary)
}
ForEach(state.selectedFiles) { file in
@@ -51,52 +52,64 @@ struct TransferComposer: View {
onRemove: { model.removeSelectedFile(file.value) }
)
}
Field(label: String(localized: "field_transfer_name"),
Field(label: String(localized: L10n.Field.transferName),
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
Field(label: String(localized: "field_sender_name"),
Field(label: String(localized: L10n.Field.senderName),
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
Text(LocalizedStringKey("send_access_title")).font(.headline)
Text(String(localized: L10n.Send.accessTitle)).font(.headline)
PolicyOption(
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",
icon: .checkmarkShield, titleKey: L10n.Send.accessApproval, descKey: L10n.Send.accessApprovalDescription,
selected: state.accessPolicy == .requireApproval,
onTap: { model.setAccessPolicy(.requireApproval) }
)
PolicyOption(
icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description",
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: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill")
Label(String(localized: L10n.Send.accessAnyoneWarning), systemSymbol: .exclamationmarkTriangleFill)
.font(.caption).foregroundStyle(.orange)
}
actions
}
}
@ViewBuilder
private var actions: some View {
let shareTitle = state.isSharing
? String(localized: "button_sharing_file") : String(localized: "button_share_file")
let shareButton = PrimaryButton(
title: shareTitle, action: model.createShare,
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
)
if windowClass == .phone {
VStack(spacing: 8) {
shareButton
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
}
} else {
HStack(spacing: 8) {
shareButton.fixedSize()
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !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 {
@@ -116,7 +129,7 @@ private struct SelectedFileCard: View {
Spacer()
if canRemove {
Button(role: .destructive, action: onRemove) {
Image(systemName: "trash")
Image(systemSymbol: .trash)
}
.buttonStyle(.borderless)
.tint(.red)
@@ -128,32 +141,32 @@ private struct SelectedFileCard: View {
}
private var subtitle: String {
if file.isDirectory { return String(localized: "send_folder_label") }
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
if let size = file.sizeBytes { return formatBytes(size) }
return String(localized: "send_file_size_unknown")
return String(localized: L10n.Send.fileSizeUnknown)
}
}
private struct PolicyOption: View {
let icon: String
let titleKey: String
let descKey: String
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(systemName: icon)
Image(systemSymbol: icon)
.font(.system(size: 20))
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: 22)
VStack(alignment: .leading, spacing: 3) {
Text(LocalizedStringKey(titleKey))
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary)
Text(String(localized: titleKey))
Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
Image(systemSymbol: selected ? .checkmarkCircleFill : .circle)
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
}
.padding(14)

View File

@@ -1,4 +1,5 @@
import SwiftUI
import SFSafeSymbols
import CoreImage.CIFilterBuiltins
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
@@ -23,80 +24,78 @@ struct TransferDetailsView: View {
var body: some View {
Form {
Section {
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status))
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize))
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy))
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: "send_new_transfer_title"))
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
}
Section {
DetailDestination(
title: String(localized: "transfer_activity_title"),
description: String(localized: "transfer_activity_description"),
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: "transfer_receivers_title"),
title: String(localized: L10n.Transfer.receiversTitle),
description: receiversDescription(pendingReceivers, completedReceivers),
count: pendingReceivers + completedReceivers,
onTap: model.openReceivers
)
if transfer.invitationPresentation != .unavailable {
DetailDestination(
title: String(localized: "transfer_share_title"),
description: String(localized: "transfer_share_description"),
count: 0,
onTap: model.openShare
)
}
}
if isActiveShare {
Section {
Section {
if isActiveShare {
Button(role: .destructive) {
showStopConfirmation = true
} label: {
Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle")
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(LocalizedStringKey("send_transfer_details_title")))
.navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestDeleteTransfer) {
Image(systemName: "trash")
Button(action: model.openShare) {
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
}
.help(String(localized: L10n.Transfer.shareTitle))
}
}
.confirmationDialog(
Text(LocalizedStringKey("send_stop_sharing")),
Text(String(localized: L10n.Send.stopSharing)),
isPresented: $showStopConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "send_stop_sharing"), role: .destructive) {
Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
model.stopSharing(transferId: transfer.transferId)
}
Button(String(localized: "button_cancel"), role: .cancel) {}
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
} message: {
Text(LocalizedStringKey("send_stop_sharing_description"))
Text(String(localized: L10n.Send.stopSharingDescription))
}
}
}
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
if pending > 0 && completed > 0 {
return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))"
return L10n.Format.separatedPair(
first: L10n.Transfer.receiversPending(count: pending),
second: L10n.Transfer.receiversCompletedCount(count: completed))
}
if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) }
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) }
return String(localized: "transfer_receivers_description")
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 {
@@ -113,11 +112,11 @@ private struct DetailDestination: View {
}
Spacer()
if count > 0 {
Text("\(count)")
Text(verbatim: "\(count)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Image(systemName: "chevron.forward")
Image(systemSymbol: .chevronForward)
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
@@ -143,7 +142,8 @@ struct DetailPanelContent: View {
loading: model.state.isLoadingReceivers,
events: model.coreState.events,
transferTotalSize: transfer.totalSize,
onCancel: model.cancelReceiver
onCancel: model.cancelReceiver,
onAccept: model.acceptReceiver
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
@@ -173,13 +173,13 @@ struct TransferActivityPanel: View {
let visible = events
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
.sorted { $0.timestamp > $1.timestamp }
PanelContainer(title: String(localized: "transfer_activity_title")) {
PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
if visible.isEmpty {
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter)
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(LocalizedStringKey(event.activityTitleKey))
Text(String(localized: event.activityTitleKey))
.fontWeight(.medium).padding(.vertical, 14)
}
}
@@ -194,17 +194,23 @@ struct ReceiverHistoryPanel: View {
let events: [CoreEventModel]
let transferTotalSize: UInt64
let onCancel: (String) -> Void
let onAccept: (String) -> Void
var body: some View {
PanelContainer(title: String(localized: "transfer_receivers_title")) {
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
if loading {
ProgressView().frame(maxWidth: .infinity).padding(40)
} else if receivers.isEmpty {
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter)
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)
ReceiverRow(
receiver: receiver,
sendProgress: sendProgress(for: receiver),
onCancel: onCancel,
onAccept: onAccept
)
}
}
}
@@ -225,6 +231,7 @@ private struct ReceiverRow: View {
let receiver: ReceiverRequestModel
let sendProgress: TransferProgress?
let onCancel: (String) -> Void
let onAccept: (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
@@ -234,9 +241,10 @@ private struct ReceiverRow: View {
}
var body: some View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
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)
@@ -246,24 +254,38 @@ private struct ReceiverRow: View {
if showLive, let sendProgress {
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
} else {
Text(LocalizedStringKey(receiver.status.statusTextKey))
Text(String(localized: receiver.status.statusTextKey))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(receiver.status.statusColor(colors))
}
if let reason = receiver.reason, !reason.isEmpty {
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
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(LocalizedStringKey("button_refuse"))
.font(VniType.bodySmall)
VStack(alignment: .trailing, spacing: 8) {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
}
.buttonStyle(.borderless)
.tint(.red)
// Fallback approve action, in case the approval modal didn't surface.
Button {
onAccept(receiver.id)
} label: {
Text(String(localized: L10n.Button.approve))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(.white)
.padding(.horizontal, 16).padding(.vertical, 7)
.background(Color.green, in: Capsule())
}
.buttonStyle(.plain)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -277,24 +299,22 @@ struct TransferSharePanel: View {
let transfer: Transfer
var body: some View {
PanelContainer(title: String(localized: "transfer_share_title")) {
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(LocalizedStringKey("transfer_scan_qr"))
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(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable:
Text(LocalizedStringKey(
transfer.status == .failed ? "transfer_event_failed" : "transfer_event_stopped"
))
.foregroundStyle(colors.foregroundLighter)
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
.foregroundStyle(colors.foregroundLighter)
}
}
}
@@ -305,9 +325,9 @@ struct TransferSharePanel: View {
image.interpolation(.none).resizable().scaledToFit().padding(14)
} else {
VStack(spacing: 10) {
Image(systemName: "qrcode")
Image(systemSymbol: .qrcode)
.font(.system(size: 36, weight: .medium))
Text(LocalizedStringKey("transfer_qr_unavailable"))
Text(String(localized: L10n.Transfer.qrUnavailable))
.font(VniType.bodySmall)
.multilineTextAlignment(.center)
}
@@ -373,38 +393,39 @@ extension CoreEventModel {
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
}
var activityTitleKey: String {
if phase == "import" && kind == "started" { return "transfer_event_preparing" }
if phase == "ticket" && kind == "created" { return "transfer_event_ready" }
if phase == "network" { return "transfer_event_connecting" }
if phase == "download" { return "transfer_event_downloading" }
if phase == "export" { return "transfer_event_saving" }
if kind == "receiver-requested" { return "transfer_event_requested" }
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" }
if kind == "receiver-refused" { return "transfer_event_refused" }
if kind == "receiver-completed" { return "transfer_event_completed" }
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" }
if kind == "failed" { return "transfer_event_failed" }
return "transfer_event_updated"
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 {
var statusTextKey: String.LocalizationValue {
switch self {
case .requested: return "transfer_receiver_requested"
case .accepted: return "transfer_receiver_accepted"
case .refused: return "transfer_receiver_refused"
case .expired: return "transfer_receiver_expired"
case .completed: return "transfer_receiver_completed"
case .unknown: return "transfer_receiver_unknown"
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: return colors.destructiveDefault
case .refused, .expired, .failed: return colors.destructiveDefault
default: return colors.foregroundLighter
}
}

View File

@@ -2,7 +2,7 @@ import SwiftUI
enum NfcShareAvailability { case available, unavailable, hidden }
/// Invitation delivery actions shared by the native Apple feature models.
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
/// Platform implementations perform export, native share, and NFC write.
@MainActor
protocol TransferShareActions: AnyObject {
@@ -30,7 +30,7 @@ struct ShareActionsView: View {
VStack(spacing: 12) {
if actions.nfcAvailability != .hidden {
SecondaryButton(
title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"),
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
action: {
writingNfc = true
actions.writeInvitationToNfc(ticket: ticket) { result in
@@ -41,16 +41,16 @@ struct ShareActionsView: View {
enabled: actions.nfcAvailability == .available && !writingNfc
)
if actions.nfcAvailability == .unavailable {
Text(LocalizedStringKey("transfer_nfc_unavailable"))
Text(String(localized: L10n.Transfer.nfcUnavailable))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
}
SecondaryButton(title: String(localized: "button_download_invitation"), action: {
SecondaryButton(title: String(localized: L10n.Button.downloadInvitation), action: {
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.export, $0)
}
})
PrimaryButton(title: String(localized: "button_native_share"), action: {
PrimaryButton(title: String(localized: L10n.Button.nativeShare), action: {
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.share, $0)
}

View File

@@ -20,7 +20,7 @@ protocol BugReportService {
/// 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.message("Bug reporting is not configured"))
.failure(InvitationError.bugReportingUnavailable)
}
func previewLogBytes() async -> Int { 0 }
}

View File

@@ -12,16 +12,16 @@ enum SettingsSection: Hashable {
case about
case bugReport
var titleKey: String {
var titleKey: String.LocalizationValue {
switch self {
case .overview: return "settings_title"
case .preferences: return "preferences_title"
case .appearance: return "appearance_title"
case .notifications: return "notifications_title"
case .network: return "settings_network_title"
case .storage: return "storage_title"
case .about: return "about_title"
case .bugReport: return "about_bug_report"
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
}
}
}
@@ -43,7 +43,6 @@ struct SettingsState: Equatable {
var isValidatingFolder = false
var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system
var notificationsEnabled = false
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var relayMode: RelayPreferenceMode = .automatic
@@ -53,7 +52,7 @@ struct SettingsState: Equatable {
var isApplyingRelayConfiguration = false
var hasActiveNetworkWork = false
var endpointId: String?
var relayApplyErrorKey: String?
var relayApplyErrorKey: String.LocalizationValue?
var deviceInfo: DeviceInfo?
var appVersion = ""
var isLoadingDeviceInfo = false
@@ -66,14 +65,16 @@ struct SettingsState: Equatable {
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.notificationsEnabled == rhs.notificationsEnabled
&& lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
@@ -89,7 +90,9 @@ struct SettingsState: Equatable {
&& 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
}
}
@@ -110,7 +113,6 @@ final class SettingsModel: ObservableObject {
private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var enableNotificationsAfterSettings = false
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
private var hasRelayConfigurationDraft = false
@@ -149,7 +151,6 @@ final class SettingsModel: ObservableObject {
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.notificationsEnabled = prefs.notificationsEnabled
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode
@@ -168,7 +169,7 @@ final class SettingsModel: ObservableObject {
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
self.state.hasActiveNetworkWork = hasActiveWork
self.state.endpointId = coreState.status?.endpointId
if !hasActiveWork && self.state.relayApplyErrorKey == "relay_apply_active_transfers" {
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
self.state.relayApplyErrorKey = nil
}
}
@@ -205,29 +206,26 @@ final class SettingsModel: ObservableObject {
}
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() }
func setNotificationsEnabled(_ enabled: Bool) {
/// 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 {
if !enabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
return
}
let permission = await notifications.requestPermission()
state.notificationPermission = permission
if permission == .granted {
await enableNotifications()
} else {
preferences.setNotificationsEnabled(false)
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
messages.show(UiMessage(
text: .resource(key),
tone: .warning,
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
))
if permission == .unsupported {
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
}
}
}
@@ -237,7 +235,7 @@ final class SettingsModel: ObservableObject {
Task {
preferences.setDiagnosticsEnabled(enabled)
messages.show(UiMessage(
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
tone: .success
))
}
@@ -298,8 +296,8 @@ final class SettingsModel: ObservableObject {
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
guard !hasActiveWork else {
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = "relay_apply_active_transfers"
messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning))
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
return
}
@@ -318,16 +316,16 @@ final class SettingsModel: ObservableObject {
preferences.setRelayConfiguration(configuration)
state.isApplyingRelayConfiguration = false
state.relayConfigurationIsDirty = false
messages.show(UiMessage(text: .resource("relay_settings_applied"), tone: .success))
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 = "relay_apply_active_transfers"
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
case .transitionInProgress:
state.relayApplyErrorKey = "relay_apply_failed"
state.relayApplyErrorKey = L10n.Relay.applyFailed
}
return
}
@@ -337,11 +335,11 @@ final class SettingsModel: ObservableObject {
)
state.isApplyingRelayConfiguration = false
if case .success = rollbackResult {
state.relayApplyErrorKey = "relay_apply_failed"
messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error))
state.relayApplyErrorKey = L10n.Relay.applyFailed
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
} else {
state.relayApplyErrorKey = "relay_restore_failed"
messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error))
state.relayApplyErrorKey = L10n.Relay.restoreFailed
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
}
}
}
@@ -369,11 +367,11 @@ final class SettingsModel: ObservableObject {
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
if what.isEmpty {
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
return
}
if expected.isEmpty {
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
return
}
state.isSubmittingBugReport = true
@@ -392,58 +390,55 @@ final class SettingsModel: ObservableObject {
state.bugSteps = ""
state.bugContact = ""
state.bugIncludeLogs = true
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
onSuccess()
case .failure:
state.isSubmittingBugReport = false
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
}
}
}
func openNotificationSettings() {
Task {
enableNotificationsAfterSettings = true
let result = await notifications.openSettings()
if case .failure = result {
enableNotificationsAfterSettings = false
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
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 {
let permission = await notifications.refreshPermission()
state.notificationPermission = permission
if enableNotificationsAfterSettings {
enableNotificationsAfterSettings = false
if permission == .granted { await enableNotifications() }
} else if permission != .granted && state.notificationsEnabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
}
state.notificationPermission = await notifications.refreshPermission()
}
}
private func enableNotifications() async {
preferences.setNotificationsEnabled(true)
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
}
// MARK: - Storage
/// Recomputes the on-disk usage breakdown off the main actor.
/// 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 {
@@ -478,13 +473,90 @@ final class SettingsModel: ObservableObject {
state.isDeletingTransfers = false
if failures == 0 {
loadStorageUsage()
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
} else {
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
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]

View File

@@ -1,4 +1,5 @@
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.
@@ -23,39 +24,54 @@ struct SettingsScreen: View {
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: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
}
NavigationLink(value: SettingsSection.appearance) {
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
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: "notifications_title"), value: nil)
SettingsRow(icon: .bell, title: String(localized: L10n.Notifications.title), value: nil)
}
NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
}
}
Section(String(localized: "settings_advanced_title")) {
Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) {
SettingsRow(
icon: "network",
title: String(localized: "settings_network_title"),
icon: .network,
title: String(localized: L10n.Settings.networkTitle),
value: relayModeLabel(model.state.relayMode)
)
}
}
Section {
NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("settings_title")))
.navigationTitle(Text(String(localized: L10n.Settings.title)))
.navigationDestination(for: SettingsSection.self) { section in
sectionForm(section)
}
@@ -68,7 +84,7 @@ struct SettingsScreen: View {
SettingsSectionContent(model: model, section: section)
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
.navigationTitle(Text(String(localized: section.titleKey)))
if section == .about {
content
@@ -80,7 +96,7 @@ struct SettingsScreen: View {
Button {
showBugReport = true
} label: {
Label(String(localized: "about_bug_report"), systemImage: "ladybug")
Label(String(localized: L10n.About.bugReport), systemSymbol: .ladybug)
}
}
}
@@ -124,30 +140,30 @@ private struct SettingsSectionContent: View {
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
switch mode {
case .automatic: return String(localized: "relay_mode_automatic")
case .strictCustom: return String(localized: "relay_mode_custom")
case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback")
case .localOnly: return String(localized: "relay_mode_local_only")
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 relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String {
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
switch mode {
case .automatic: return "relay_mode_automatic_description"
case .strictCustom: return "relay_mode_custom_description"
case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description"
case .localOnly: return "relay_mode_local_only_description"
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: String
let icon: SFSymbol
let title: String
let value: String?
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
Image(systemSymbol: icon)
.foregroundStyle(.tint)
.frame(width: 26)
Text(title).foregroundStyle(.primary)
@@ -161,8 +177,8 @@ struct SettingsRow: View {
func themeModeLabel(_ mode: ThemeMode) -> String {
switch mode {
case .system: return String(localized: "appearance_system_mode")
case .light: return String(localized: "appearance_light_mode")
case .dark: return String(localized: "appearance_dark_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

@@ -1,4 +1,5 @@
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.
@@ -7,16 +8,26 @@ struct PreferencesSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section(String(localized: "field_username")) {
TextField(String(localized: "field_username"),
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: "preferences_receive_folder_title")) {
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
.foregroundStyle(.secondary)
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
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)
}
}
}
}
@@ -27,7 +38,7 @@ struct AppearanceSettings: View {
var body: some View {
Section {
Picker(String(localized: "appearance_title"),
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)
@@ -44,16 +55,22 @@ struct NotificationSettings: View {
var body: some View {
Section {
Toggle(isOn: Binding(
get: { model.state.notificationsEnabled },
set: { model.setNotificationsEnabled($0) }
)) {
Text(LocalizedStringKey("notifications_local_title"))
}
if model.state.notificationPermission == .denied {
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
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() }
}
}
@@ -63,7 +80,7 @@ struct NetworkSettings: View {
var body: some View {
Section {
Picker(
String(localized: "settings_network_title"),
"",
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
) {
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
@@ -71,24 +88,27 @@ struct NetworkSettings: View {
}
}
.pickerStyle(.inline)
.labelsHidden()
.disabled(model.state.isApplyingRelayConfiguration)
} header: {
Text(String(localized: L10n.Settings.networkTitle))
} footer: {
Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode)))
Text(String(localized: relayModeDescription(model.state.relayMode)))
}
Section {
Label {
Text(LocalizedStringKey("relay_privacy_description"))
Text(String(localized: L10n.Relay.privacyDescription))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "lock.shield")
Image(systemSymbol: .lockShield)
}
.foregroundStyle(.secondary)
}
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
Section {
Text(String(format: String(localized: "approval_endpoint_id"), endpointId))
Text(L10n.Approval.endpointId(deviceId: endpointId))
.font(.footnote.monospaced())
.textSelection(.enabled)
}
@@ -98,10 +118,10 @@ struct NetworkSettings: View {
Section {
if model.state.relayMode == .strictCustom {
Label {
Text(LocalizedStringKey("relay_strict_warning"))
Text(String(localized: L10n.Relay.strictWarning))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "exclamationmark.shield.fill")
Image(systemSymbol: .exclamationmarkShieldFill)
}
.foregroundStyle(.orange)
}
@@ -110,7 +130,7 @@ struct NetworkSettings: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
TextField(
"https://relay.example.com",
"",
text: Binding(
get: {
model.state.relayURLs.indices.contains(index)
@@ -118,8 +138,12 @@ struct NetworkSettings: View {
: ""
},
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)
@@ -130,10 +154,10 @@ struct NetworkSettings: View {
Button(role: .destructive) {
model.removeRelayURL(at: index)
} label: {
Image(systemName: "minus.circle.fill")
Image(systemSymbol: .minusCircleFill)
}
.buttonStyle(.borderless)
.accessibilityLabel(Text(LocalizedStringKey("relay_remove_url")))
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
.disabled(model.state.isApplyingRelayConfiguration)
}
@@ -146,16 +170,16 @@ struct NetworkSettings: View {
}
Button(action: model.addRelayURL) {
Label(String(localized: "relay_add_url"), systemImage: "plus.circle")
Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
}
.disabled(
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|| model.state.isApplyingRelayConfiguration
)
} header: {
Text(LocalizedStringKey("relay_custom_urls_label"))
Text(String(localized: L10n.Relay.customUrlsLabel))
} footer: {
Text(LocalizedStringKey("relay_custom_urls_help"))
Text(String(localized: L10n.Relay.customUrlsHelp))
}
}
@@ -164,7 +188,7 @@ struct NetworkSettings: View {
Label {
Text(relayValidationMessage(error))
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
Image(systemSymbol: .exclamationmarkTriangleFill)
}
.foregroundStyle(.red)
}
@@ -173,13 +197,9 @@ struct NetworkSettings: View {
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
Section {
Label {
Text(LocalizedStringKey(
model.state.hasActiveNetworkWork
? "relay_apply_active_transfers"
: model.state.relayApplyErrorKey ?? "relay_apply_failed"
))
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
Image(systemSymbol: .exclamationmarkTriangleFill)
}
.foregroundStyle(.red)
}
@@ -188,9 +208,7 @@ struct NetworkSettings: View {
Section {
Button(action: model.applyRelayConfiguration) {
HStack {
Text(LocalizedStringKey(
model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply"
))
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
if model.state.isApplyingRelayConfiguration {
Spacer()
ProgressView()
@@ -203,7 +221,7 @@ struct NetworkSettings: View {
|| model.state.hasActiveNetworkWork
)
} footer: {
Text(LocalizedStringKey("relay_apply_restart_description"))
Text(String(localized: L10n.Relay.applyRestartDescription))
}
}
}
@@ -211,18 +229,15 @@ struct NetworkSettings: View {
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
switch error {
case .missingURL:
return String(localized: "relay_validation_missing_url")
return String(localized: L10n.Relay.validationMissingUrl)
case .tooManyURLs:
return String(
format: String(localized: "relay_validation_too_many_urls"),
RelayConfigurationValidator.maximumRelayCount
)
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
case .httpsRequired(let index):
return String(format: String(localized: "relay_validation_https_required"), index + 1)
return L10n.Relay.validationHttpsRequired(line: index + 1)
case .invalidURL(let index):
return String(format: String(localized: "relay_validation_invalid_url"), index + 1)
return L10n.Relay.validationInvalidUrl(line: index + 1)
case .duplicateURL(let index):
return String(format: String(localized: "relay_validation_duplicate_url"), index + 1)
return L10n.Relay.validationDuplicateUrl(line: index + 1)
}
}
@@ -230,57 +245,131 @@ 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 {
if let storage = model.state.storage {
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles))
LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache))
LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData))
LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary))
LabeledContent(String(localized: "storage_total")) {
Text(formatBytes(storage.total)).fontWeight(.semibold)
}
} else {
HStack {
Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary)
Spacer()
ProgressView()
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(LocalizedStringKey("storage_footer"))
Text(String(localized: L10n.Storage.footer))
}
// Reclaim reversible junk (temp + trash) non-destructive to history.
Section {
Button(role: .destructive) {
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: {
HStack {
Text(model.state.isDeletingTransfers
? String(localized: "storage_deleting")
: String(localized: "storage_delete_transfers"))
if model.state.isDeletingTransfers {
Spacer()
ProgressView()
}
}
actionLabel(
title: L10n.Storage.deleteTransfers,
busyTitle: L10n.Storage.deleting,
isBusy: model.state.isDeletingTransfers,
symbol: .trash,
tint: .red
)
}
.disabled(model.state.isDeletingTransfers)
.buttonStyle(.plain)
.disabled(isBusy)
} footer: {
Text(String(localized: L10n.Storage.deleteTransfersCaption))
}
.onAppear { model.loadStorageUsage() }
.task { model.loadStorageUsage() }
.confirmationDialog(
Text(LocalizedStringKey("storage_delete_transfers")),
Text(String(localized: L10n.Storage.deleteTransfers)),
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "storage_delete_transfers"), role: .destructive) {
Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) {
model.deleteAllTransfers()
}
Button(String(localized: "button_cancel"), role: .cancel) {}
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
} message: {
Text(LocalizedStringKey("storage_delete_transfers_description"))
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 {
@@ -290,40 +379,40 @@ struct AboutSettings: View {
var body: some View {
Section {
Text(LocalizedStringKey("about_tagline")).font(.headline)
Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary)
Text(String(localized: L10n.About.tagline)).font(.headline)
Text(String(localized: L10n.About.description)).foregroundStyle(.secondary)
}
Section(String(localized: "about_is_title")) {
AboutPoint("about_is_direct", "paperplane")
AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark")
AboutPoint("about_is_in_control", "checkmark.shield")
AboutPoint("about_is_encrypted", "lock")
AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right")
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: "about_isnt_title")) {
AboutPoint("about_isnt_cloud", "icloud.slash")
AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath")
AboutPoint("about_isnt_public", "megaphone")
Section(String(localized: L10n.About.isntTitle)) {
AboutPoint(L10n.About.isntCloud, .icloudSlash)
AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath)
AboutPoint(L10n.About.isntPublic, .megaphone)
}
Section(String(localized: "about_privacy_title")) {
AboutPoint("about_privacy_capability", "qrcode")
AboutPoint("about_privacy_deny", "hand.raised")
AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right")
AboutPoint("about_privacy_local", "internaldrive")
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: "about_title")) {
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
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: "device_model_title"), value: device.deviceModel ?? "")
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "")
LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem)
}
LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0")
LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0")
Link(destination: Self.privacyPolicyURL) {
Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised")
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
}
}
@@ -333,7 +422,7 @@ struct AboutSettings: View {
get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) }
)) {
Text(LocalizedStringKey("diagnostics_title"))
Text(String(localized: L10n.Diagnostics.title))
}
}
}
@@ -357,13 +446,13 @@ struct BugReportSheet: View {
BugReportSettings(model: model, onSubmitted: { dismiss() })
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("about_bug_report")))
.navigationTitle(Text(String(localized: L10n.About.bugReport)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "button_cancel")) { dismiss() }
Button(String(localized: L10n.Button.cancel)) { dismiss() }
}
}
}
@@ -373,21 +462,21 @@ struct BugReportSheet: View {
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
private struct AboutPoint: View {
let key: String
let symbol: String
let key: String.LocalizationValue
let symbol: SFSymbol
init(_ key: String, _ symbol: String) {
init(_ key: String.LocalizationValue, _ symbol: SFSymbol) {
self.key = key
self.symbol = symbol
}
var body: some View {
Label {
Text(LocalizedStringKey(key))
Text(String(localized: key))
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: symbol).foregroundStyle(.tint)
Image(systemSymbol: symbol).foregroundStyle(.tint)
}
}
}
@@ -397,36 +486,36 @@ struct BugReportSettings: View {
var onSubmitted: () -> Void = {}
var body: some View {
Section(String(localized: "bug_report_what_label")) {
Section(String(localized: L10n.Bug.reportWhatLabel)) {
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical)
.lineLimit(3, reservesSpace: true)
.labelsHidden()
}
Section(String(localized: "bug_report_expected_label")) {
Section(String(localized: L10n.Bug.reportExpectedLabel)) {
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical)
.lineLimit(3, reservesSpace: true)
.labelsHidden()
}
Section(String(localized: "bug_report_steps_label")) {
Section(String(localized: L10n.Bug.reportStepsLabel)) {
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical)
.lineLimit(3, reservesSpace: true)
.labelsHidden()
}
Section(String(localized: "bug_report_contact_label")) {
Section(String(localized: L10n.Bug.reportContactLabel)) {
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
prompt: Text(String(localized: L10n.Bug.reportContactHint)))
.labelsHidden()
}
Section {
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
Text(LocalizedStringKey("bug_report_include_logs"))
Text(String(localized: L10n.Bug.reportIncludeLogs))
}
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
Text(model.state.isSubmittingBugReport
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit))
}
.disabled(model.state.isSubmittingBugReport)
}

View File

@@ -6,7 +6,7 @@ import UIKit
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)",
appVersion: version,
@@ -36,7 +36,7 @@ private struct IosDeviceInfoProvider: DeviceInfoProvider {
device.isBatteryMonitoringEnabled = true
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
let level = device.batteryLevel
return level >= 0 ? "\(Int(level * 100))%" : nil
return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil
}()
return DeviceInfo(
deviceName: device.name,

View File

@@ -5,7 +5,7 @@ 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 version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,

View File

@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else {
return .failure(InvitationError.message("The receive folder is not VniDrop Documents"))
return .failure(InvitationError.filesystemUnavailable)
}
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
return .failure(InvitationError.message("The Files location URL is unavailable"))
return .failure(InvitationError.filesystemUnavailable)
}
let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async {
@@ -44,7 +44,7 @@ struct IosFileSystemService: FileSystemService {
}
}
}
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files"))
return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable)
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
return .failure(InvitationError.shareEmpty)
}
let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources(

View File

@@ -42,8 +42,24 @@ struct MacFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
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)
}

View File

@@ -107,9 +107,15 @@ enum PickerSupport {
)
#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
isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
)
#endif
}

View File

@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
picker.delegate = self
picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
return onResult(.failure(InvitationError.viewControllerUnavailable))
}
presenter.present(picker, animated: true)
}
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
return onResult(.failure(InvitationError.viewControllerUnavailable))
}
ensureCameraAccess { [weak self] granted in
guard let self else { return }
guard granted else {
return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes")))
return onResult(.failure(InvitationError.cameraUnavailable))
}
let scanner = QrScannerViewController { result in
self.qrController = nil
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard NFCNDEFReaderSession.readingAvailable else {
return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device")))
return onResult(.failure(InvitationError.nfcUnavailable))
}
let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
let result = documentResult
documentResult = nil
result?(Result {
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") }
guard let url = urls.first else { throw InvitationError.invalidInvitationURL }
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
}
func cancelScan() {
finish(.failure(InvitationError.message("QR scanning was cancelled")))
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.message("No camera is available")))
return finish(.failure(InvitationError.cameraUnavailable))
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
return finish(.failure(InvitationError.cameraUnavailable))
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
reader.alertMessage = String(localized: L10n.Receive.nfcWaiting)
session = reader
reader.begin()
}
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200
finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription)))
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
.flatMap { $0.records }
.compactMap { payloadAsInvitation($0) }
.first
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") }
guard let ticket else { throw InvitationError.nfcFailed }
return ticket
}
session.invalidate()

View File

@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
}
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled")))
onResult(.failure(InvitationError.cancelled))
return
}
onResult(Result {
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS")))
onResult(.failure(InvitationError.qrUnavailable))
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
onResult(.failure(InvitationError.nfcUnavailable))
}
func cancel() {}

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

@@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
onResult(.failure(InvitationError.nfcUnavailable))
return
}
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
@MainActor
private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else {
throw InvitationError.message("Could not find an iOS view controller")
throw InvitationError.viewControllerUnavailable
}
presenter.present(controller, animated: true)
}
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting)
session = reader
reader.begin()
}
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription)))
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.message("No NFC tag was detected")))
return finish(.failure(InvitationError.nfcFailed))
}
// CoreNFC completion handlers run on the session's `.main` queue; these
// framework values are safe to use there.
@@ -109,18 +109,18 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
if let queryError { return self.finish(.failure(queryError)) }
switch status {
case .notSupported:
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
self.finish(.failure(InvitationError.nfcFailed))
case .readOnly:
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
self.finish(.failure(InvitationError.nfcFailed))
default:
guard let message = self.invitationMessage() else {
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC")))
return self.finish(.failure(InvitationError.nfcFailed))
}
tag.writeNDEF(message) { writeError in
if let writeError {
self.finish(.failure(writeError))
} else {
session.alertMessage = "Invitation written"
session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
session.invalidate()
self.finish(.success(()))
}

View File

@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
panel.allowedContentTypes = []
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled")))
onResult(.failure(InvitationError.cancelled))
return
}
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.message("No window available")))
onResult(.failure(InvitationError.noWindowAvailable))
return
}
let picker = NSSharingServicePicker(items: [url])
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
onResult(.failure(InvitationError.nfcUnavailable))
}
func cancelNfcWrite() {}

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

@@ -1,7 +0,0 @@
{
"images" : [
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "app-icon.png" },
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "app-icon.png" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -20,6 +20,12 @@
<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>
@@ -51,6 +57,20 @@
<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>
@@ -63,10 +83,12 @@
<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>fetch</string>
<string>processing</string>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,12 @@
<!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 NDEF reading. -->
<!-- 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>NDEF</string>
<string>TAG</string>
</array>
<!-- macOS App Sandbox: user-selected files for share/receive, and network

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

@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
@Binding var isPresented: Bool
let windowClass: WindowClass
let onDismiss: () -> Void
/// Fired after the sheet's dismissal animation completes (as opposed to
/// `onDismiss`, which requests the close). Lets callers serialize a follow-up
/// sheet against this one's actual teardown instead of guessing a delay.
let onDismissed: (() -> Void)?
@ViewBuilder let drawerContent: () -> DrawerContent
func body(content: Content) -> some View {
content.sheet(
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
onDismiss: onDismissed
) {
SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone))
@@ -41,7 +46,7 @@ private struct SheetChrome<Content: View>: View {
ScrollView { content().padding(.top, 4) }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "button_close"), action: onClose)
Button(String(localized: L10n.Button.close), action: onClose)
}
}
#if os(iOS)
@@ -56,11 +61,12 @@ extension View {
isPresented: Binding<Bool>,
windowClass: WindowClass,
onDismiss: @escaping () -> Void,
onDismissed: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> DrawerContent
) -> some View {
modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, drawerContent: content
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
))
}
}

View File

@@ -30,7 +30,7 @@ struct StatusPill: View {
// MARK: - ProgressRow
struct ProgressRow: View {
let labelKey: String
let labelKey: String.LocalizationValue
let progress: Double?
var detail: String? = nil
/// Pre-resolved label; when set it overrides `labelKey`.
@@ -42,7 +42,7 @@ struct ProgressRow: View {
label.font(.subheadline).lineLimit(1)
Spacer()
if let progress {
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
}
}
if let detail {
@@ -62,7 +62,7 @@ struct ProgressRow: View {
if let labelText {
Text(labelText)
} else {
Text(LocalizedStringKey(labelKey))
Text(String(localized: labelKey))
}
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import SFSafeSymbols
/// Bottom toast host driven by `UiMessageController`, ported from
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
@@ -52,7 +53,7 @@ struct SnackbarHost: View {
.buttonStyle(.borderless)
}
Button(action: dismiss) {
Image(systemName: "xmark")
Image(systemSymbol: .xmark)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 36, height: 36)

View File

@@ -4,14 +4,14 @@ 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) // Localizable.xcstrings key
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 key): return String(localized: String.LocalizationValue(key))
case .resource(let value): return String(localized: value)
}
}
}

View File

@@ -5,41 +5,45 @@ import VnidropCore
/// `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("error_invalid_ticket")
return .resource(L10n.Error.invalidTicket)
case .Permission:
return .resource("error_permission")
return .resource(L10n.Error.permission)
case .Filesystem:
return .resource("error_filesystem")
return .resource(L10n.Error.filesystem)
case .FilesystemPermission:
return .resource("error_filesystem")
return .resource(L10n.Error.filesystem)
case .DestinationExists:
return .resource("error_destination_exists")
return .resource(L10n.Error.destinationExists)
case .StorageFull:
return .resource("error_storage_full")
return .resource(L10n.Error.storageFull)
case .Network:
return .resource("error_network")
return .resource(L10n.Error.network)
case .Transfer(let reason):
return transferUiText(reason)
case .Repository:
return .resource("error_repository")
return .resource(L10n.Error.repository)
case .Cancelled:
return .resource("error_generic")
return .resource(L10n.Error.generic)
case .InvalidInput:
return .resource("error_invalid_input")
return .resource(L10n.Error.invalidInput)
case .Initialization(let reason):
return initializationUiText(reason)
case .Internal(let reason):
return reasonHints(reason) ?? .resource("error_generic")
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
return reasonHints(technicalDetail) ?? .resource("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 {
@@ -76,23 +80,89 @@ extension Error {
}
}
/// 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("error_permission")
return .resource(L10n.Error.permission)
}
return .resource("error_transfer")
return .resource(L10n.Error.transfer)
}
private func initializationUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("native") && detail.contains("library") {
return .resource("error_missing_native_library")
return .resource(L10n.Error.missingNativeLibrary)
}
if detail.contains("socket") || detail.contains("bind") {
return .resource("error_socket_bind")
return .resource(L10n.Error.socketBind)
}
return .resource("error_initialization")
return .resource(L10n.Error.initialization)
}
private func reasonHints(_ detailRaw: String) -> UiText? {
@@ -100,50 +170,50 @@ private func reasonHints(_ detailRaw: String) -> UiText? {
if detail.isEmpty { return nil }
if detail.contains("still starting") || detail.contains("starting up") {
return .resource("error_starting_up")
return .resource(L10n.Error.startingUp)
}
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
return .resource("error_invitation_empty")
return .resource(L10n.Error.invitationEmpty)
}
if detail.contains("select at least one") || detail.contains("no files found") {
return .resource("error_share_empty")
return .resource(L10n.Error.shareEmpty)
}
if detail.contains("camera") {
return .resource("error_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("error_nfc")
return .resource(L10n.Error.nfc)
}
if detail.contains("native") && detail.contains("library") {
return .resource("error_missing_native_library")
return .resource(L10n.Error.missingNativeLibrary)
}
if detail.contains("socket") || detail.contains("bind") {
return .resource("error_socket_bind")
return .resource(L10n.Error.socketBind)
}
if detail.contains("device information") || detail.contains("device info") {
return .resource("error_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("error_permission")
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("error_invalid_ticket")
return .resource(L10n.Error.invalidTicket)
}
if detail.contains("selected")
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
return .resource("error_selection_failed")
return .resource(L10n.Error.selectionFailed)
}
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
return .resource("error_selection_failed")
return .resource(L10n.Error.selectionFailed)
}
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|| detail.contains("view controller") {
return .resource("error_selection_failed")
return .resource(L10n.Error.selectionFailed)
}
return nil
}

View File

@@ -1,4 +1,5 @@
import Foundation
import SFSafeSymbols
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
enum AppDestination: String, CaseIterable, Identifiable {
@@ -8,20 +9,20 @@ enum AppDestination: String, CaseIterable, Identifiable {
var id: String { rawValue }
var labelKey: String {
var labelKey: String.LocalizationValue {
switch self {
case .send: return "nav_send"
case .receive: return "nav_receive"
case .settings: return "nav_settings"
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 systemImage: String {
var systemSymbol: SFSymbol {
switch self {
case .send: return "paperplane"
case .receive: return "tray.and.arrow.down"
case .settings: return "gearshape"
case .send: return .paperplane
case .receive: return .trayAndArrowDown
case .settings: return .gearshape
}
}
}

View File

@@ -52,7 +52,9 @@ struct VniDropColors {
}
extension VniDropColors {
/// The single brand accent used app-wide as the SwiftUI tint.
/// 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(

View File

@@ -1,6 +1,11 @@
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
# Regenerate the project with: xcodegen generate (run from apple/)
# Requires the Rust core first: apple/scripts/build-core.sh debug
# Requires three generated inputs first (all gitignored), before xcodegen:
# - Rust core: apple/scripts/build-core.sh debug
# - Localization: (cd localization && bun run src/cli.ts generate)
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
# - Versions: packaging/version/generate-apple-xcconfig.sh all
# -> Generated/StoreVersion.xcconfig, Generated/DirectVersion.xcconfig
name: VniDrop
options:
bundleIdPrefix: com.vnidrop
@@ -9,30 +14,63 @@ options:
macOS: "15.0"
createIntermediateGroups: true
# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release
# defaults, so both are re-listed here. `Release-Direct` is a release-type config
# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store
# `VniDrop` target ships under plain `Release`.
configs:
Debug: debug
Release: release
Release-Direct: release
# Project-wide build settings (applied to every target/config).
settings:
base:
# Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and
# the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a
# universal link would fail looking for x86_64 symbols anyway.
ARCHS: arm64
# Strip unreachable code from release binaries.
DEAD_CODE_STRIPPING: YES
# Flag user-facing strings that aren't localized (the app ships 9 languages).
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: YES
packages:
VnidropCore:
path: VnidropCore
SFSafeSymbols:
url: https://github.com/SFSafeSymbols/SFSafeSymbols
from: "5.3.0"
# Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
# It is linked exclusively by the VniDropDirect target — SwiftPM links products
# per target, not per config, so keeping it off the App Store target is what
# guarantees the store binary never bundles a self-updater (App Store forbids it).
Sparkle:
url: https://github.com/sparkle-project/Sparkle
from: "2.9.4"
targets:
VniDrop:
# Shared definition for the two shipping app targets. `VniDrop` (App Store /
# TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same
# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION
# compile flag differ (set per target below).
targetTemplates:
AppBase:
type: application
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: VniDrop
excludes:
- "Resources/Info.plist"
- "Resources/VniDrop.entitlements"
- "Resources/VniDropDirect.entitlements"
- "Resources/**/.DS_Store"
settings:
base:
PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "$(PRODUCT_VERSION)"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
# (the editor reads these build settings, not the manual plist).
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
@@ -41,16 +79,68 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_USER_SCRIPT_SANDBOXING: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# App-wide accent (macOS sidebar selection, default control tints). The
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
configs:
debug:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Produce a dSYM in the archive so symbol upload succeeds.
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
release-direct:
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
dependencies:
- package: VnidropCore
- package: SFSafeSymbols
- sdk: SystemConfiguration.framework
- sdk: Security.framework
- sdk: libresolv.tbd
preBuildScripts:
# Enforce the typed-resources convention (see .swiftlint.yml). Required: fails
# the build if SwiftLint is missing so the rules can't be silently bypassed.
- name: SwiftLint (typed resources)
basedOnDependencyAnalysis: false
script: |
# Xcode runs build phases with a minimal PATH that omits Homebrew, so add
# the common Homebrew bin dirs (Apple Silicon + Intel) before resolving it.
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if which swiftlint >/dev/null; then
swiftlint lint --config "${SRCROOT}/.swiftlint.yml"
else
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
exit 1
fi
targets:
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
VniDrop:
templates: [AppBase]
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Generated/StoreVersion.xcconfig
Release: Generated/StoreVersion.xcconfig
Release-Direct: Generated/StoreVersion.xcconfig
# Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg
# and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the
# App Store target above never compiles or links it.
VniDropDirect:
templates: [AppBase]
supportedDestinations: [macOS]
configFiles:
Debug: Generated/DirectVersion.xcconfig
Release: Generated/DirectVersion.xcconfig
Release-Direct: Generated/DirectVersion.xcconfig
settings:
base:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
# Notarization requires the hardened runtime.
ENABLE_HARDENED_RUNTIME: YES
# Non-sandboxed entitlements: a sandboxed Developer ID app needs a
# provisioning profile, which direct distribution avoids. (App Store target
# keeps VniDrop.entitlements with the sandbox.)
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
dependencies:
- package: Sparkle
VniDropTests:
type: bundle.unit-test
@@ -58,6 +148,7 @@ targets:
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources:
- path: Tests
settings:
@@ -79,3 +170,21 @@ schemes:
config: Debug
targets:
- VniDropTests
# TestFlight ships the Release build; make the Archive/Profile actions explicit
# so Product → Archive can never pick up a Debug configuration.
profile:
config: Release
archive:
config: Release
# Direct-download build: always the Release-Direct config (Sparkle + notarization).
VniDropDirect:
build:
targets:
VniDropDirect: all
run:
config: Release-Direct
profile:
config: Release-Direct
archive:
config: Release-Direct

View File

@@ -0,0 +1,19 @@
<?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">
<!-- Export options for the direct-download (Developer ID) macOS build consumed by
`xcodebuild -exportArchive` in build-dmg.sh. This produces a Developer
IDsigned, hardened-runtime .app suitable for notarization and distribution
outside the Mac App Store. The App Store build uses a different flow entirely. -->
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>manual</string>
<!-- Xcode manages the Developer ID Application certificate lookup from the
keychain; the hardened runtime is enabled via ENABLE_HARDENED_RUNTIME in the
VniDropDirect target. -->
<key>teamID</key>
<string>${DEVELOPMENT_TEAM}</string>
</dict>
</plist>

173
apple/scripts/build-dmg.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
#
# Builds the direct-download macOS artifact: a Developer IDsigned, notarized
# .dmg of the VniDropDirect target (the Sparkle-enabled build). Produces:
# - apple/dist/VniDrop-<version>.dmg (signed + stapled when notarizing)
#
# This is the direct-distribution counterpart to the App Store archive flow; it
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
#
# Usage: apple/scripts/build-dmg.sh
#
# Environment:
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
# Auto-detected from the keychain when unset.
# DEVELOPMENT_TEAM Apple team ID (10 chars). Auto-derived from the identity.
# NOTARY_PROFILE Name of a `xcrun notarytool store-credentials` keychain
# profile. When set, the DMG is notarized and stapled; when
# unset the build still produces a signed DMG and prints the
# pending notarization step (useful before creds exist).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="$APPLE_DIR/dist"
BUILD_DIR="$APPLE_DIR/.build-dmg"
PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
SCHEME="VniDropDirect"
CONFIG="Release-Direct"
APP_NAME="VniDrop"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
VERSION="$("$VERSION_RESOLVER" product)"
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
BUILD_NUMBER="$("$VERSION_RESOLVER" apple-direct-build)"
"$VERSION_RESOLVER" verify >/dev/null
BUILD_METADATA="$DIST_DIR/$APP_NAME-$VERSION.build-info.json"
# --- Resolve signing identity ------------------------------------------------
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
DEVELOPER_ID_APP="$(security find-identity -v -p codesigning 2>/dev/null \
| sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1)"
fi
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
echo "error: no 'Developer ID Application' identity found in the keychain." >&2
echo " Create one in Xcode ▸ Settings ▸ Accounts, or set DEVELOPER_ID_APP." >&2
exit 1
fi
if [ -z "${DEVELOPMENT_TEAM:-}" ]; then
# The team ID is the 10-char code in the trailing parenthesis of the identity.
DEVELOPMENT_TEAM="$(printf '%s' "$DEVELOPER_ID_APP" | sed -nE 's/.*\(([A-Z0-9]{10})\)$/\1/p')"
fi
echo "==> Direct build v$VERSION (CFBundleVersion $BUILD_NUMBER)"
echo " identity: $DEVELOPER_ID_APP"
echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
# --- Build core + regenerate project ----------------------------------------
# Release core needs LTO disabled (workspace thin-LTO miscompiles proc-macros).
echo "==> Building Rust core (release)"
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
echo "==> Regenerating Xcode project"
"$VERSION_CONFIG_GENERATOR" all
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
ARCHIVE="$BUILD_DIR/$APP_NAME.xcarchive"
EXPORT_DIR="$BUILD_DIR/export"
# --- Archive + export (Developer ID) ----------------------------------------
echo "==> Archiving $SCHEME ($CONFIG)"
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIG" \
-destination 'generic/platform=macOS' \
-archivePath "$ARCHIVE" \
MARKETING_VERSION="$VERSION" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="$DEVELOPER_ID_APP" \
| xcbeautify 2>/dev/null || true
[ -d "$ARCHIVE" ] || { echo "error: archive failed" >&2; exit 1; }
echo "==> Exporting Developer ID app"
EXPORT_OPTS="$BUILD_DIR/ExportOptions.plist"
sed "s/\${DEVELOPMENT_TEAM}/$DEVELOPMENT_TEAM/" \
"$SCRIPT_DIR/ExportOptions-DeveloperID.plist" > "$EXPORT_OPTS"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTS"
APP="$EXPORT_DIR/$APP_NAME.app"
[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; }
ACTUAL_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"$APP/Contents/Info.plist")"
ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
"$APP/Contents/Info.plist")"
[ "$ACTUAL_VERSION" = "$VERSION" ] || {
echo "error: exported app version $ACTUAL_VERSION does not match $VERSION" >&2
exit 1
}
[ "$ACTUAL_BUILD" = "$BUILD_NUMBER" ] || {
echo "error: exported app build $ACTUAL_BUILD does not match $BUILD_NUMBER" >&2
exit 1
}
echo "==> Enforcing hardened-runtime signature"
"$SCRIPT_DIR/sign-exported-app.sh" \
"$APP" \
"$DEVELOPER_ID_APP" \
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
# --- Build the DMG -----------------------------------------------------------
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
rm -f "$DMG"
STAGING="$BUILD_DIR/dmg-staging"
rm -rf "$STAGING" && mkdir -p "$STAGING"
cp -R "$APP" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
echo "==> Building DMG"
if command -v create-dmg >/dev/null 2>&1; then
create-dmg \
--volname "$APP_NAME" \
--app-drop-link 380 205 \
--icon "$APP_NAME.app" 130 205 \
--window-size 540 380 \
--no-internet-enable \
"$DMG" "$STAGING" >/dev/null || {
# create-dmg exits non-zero if it can't set the fancy layout; fall back.
[ -f "$DMG" ] || hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
-ov -format UDZO "$DMG" >/dev/null
}
else
hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
-ov -format UDZO "$DMG" >/dev/null
fi
echo "==> Signing DMG"
codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
# --- Notarize + staple -------------------------------------------------------
if [ -n "${NOTARY_PROFILE:-}" ]; then
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
echo "==> Stapling"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl -a -vvv --type install "$DMG" || true
else
echo "==> NOTARY_PROFILE unset — skipping notarization."
echo " The DMG is signed but NOT notarized; Gatekeeper will block it until"
echo " you run 'xcrun notarytool store-credentials' and re-run with NOTARY_PROFILE set."
fi
SIZE="$(stat -f%z "$DMG")"
jq -n \
--arg productVersion "$VERSION" \
--arg directBuildNumber "$BUILD_NUMBER" \
--arg artifact "$(basename "$DMG")" \
'{
productVersion: $productVersion,
directBuildNumber: $directBuildNumber,
distribution: "direct",
artifact: $artifact
}' > "$BUILD_METADATA"
echo "==> Done."
echo " dmg: $DMG"
echo " version: $VERSION"
echo " build: $BUILD_NUMBER"
echo " size: $SIZE bytes"

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Generates/updates the Sparkle appcast for the direct-download build. Runs
# Sparkle's `generate_appcast` over the DMGs in apple/dist/, writing:
# - apple/dist/appcast.xml
#
# The <enclosure> URLs point at the matching GitHub Release download assets, and
# each item is signed with the project's EdDSA key (from a key file or the
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
#
# Usage: apple/scripts/generate-appcast.sh
#
# Environment:
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
# SPARKLE_BIN Dir containing generate_appcast. Auto-located when unset.
# SPARKLE_ED_KEY_FILE Path to the EdDSA private key file. When unset,
# generate_appcast reads the key from the login keychain.
# RELEASE_REPO owner/repo for enclosure URLs. Default: sudosylabs/vnidrop
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION="$("$VERSION_RESOLVER" product)"
"$VERSION_RESOLVER" verify >/dev/null
# Enclosure URLs resolve to the specific release's assets.
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
# --- Locate generate_appcast -------------------------------------------------
find_tool() {
local name="$1"
if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/$name" ]; then
printf '%s' "$SPARKLE_BIN/$name"; return 0
fi
if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return 0; fi
# Sparkle SPM artifact bundle lands under DerivedData SourcePackages.
local dd="${APPLE_DERIVED_DATA:-$HOME/Library/Developer/Xcode/DerivedData}"
local hit
hit="$(find "$dd" "$HOME/Library/Caches/org.swift.swiftpm" -type f -name "$name" \
-perm -111 2>/dev/null | head -1 || true)"
[ -n "$hit" ] && { printf '%s' "$hit"; return 0; }
return 1
}
GENERATE_APPCAST="$(find_tool generate_appcast || true)"
if [ -z "$GENERATE_APPCAST" ]; then
echo "error: generate_appcast not found. Set SPARKLE_BIN to Sparkle's bin/ dir" >&2
echo " (download from https://github.com/sparkle-project/Sparkle/releases)." >&2
exit 1
fi
echo "==> Using $GENERATE_APPCAST"
# --- Generate ----------------------------------------------------------------
args=( --download-url-prefix "$DOWNLOAD_PREFIX/" -o "$DIST_DIR/appcast.xml" )
if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then
args+=( --ed-key-file "$SPARKLE_ED_KEY_FILE" )
fi
echo "==> Generating appcast (v$VERSION) → $DIST_DIR/appcast.xml"
"$GENERATE_APPCAST" "${args[@]}" "$DIST_DIR"
echo "==> Done. Enclosure prefix: $DOWNLOAD_PREFIX/"

67
apple/scripts/notarize.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
printf 'Usage: %s <artifact> <keychain-profile> <log-output>\n' "$0" >&2
exit 2
fi
artifact=$1
keychain_profile=$2
log_output=$3
[[ -s $artifact ]] || {
printf 'error: notarization artifact is missing or empty: %s\n' "$artifact" >&2
exit 1
}
[[ -n $keychain_profile ]] || {
printf 'error: notarization keychain profile is empty\n' >&2
exit 1
}
[[ -n $log_output ]] || {
printf 'error: notarization log output path is empty\n' >&2
exit 1
}
rm -f "$log_output"
set +e
response="$(
xcrun notarytool submit "$artifact" \
--keychain-profile "$keychain_profile" \
--wait \
--output-format json
)"
submit_exit=$?
set -e
printf '%s\n' "$response"
submission_id="$(
printf '%s\n' "$response" |
jq -r '.id // empty' 2>/dev/null ||
true
)"
status="$(
printf '%s\n' "$response" |
jq -r '.status // empty' 2>/dev/null ||
true
)"
if [[ $submit_exit -eq 0 && $status == Accepted && -n $submission_id ]]; then
printf 'Notarization accepted (submission %s)\n' "$submission_id"
exit 0
fi
printf 'error: notarization was not accepted (status: %s, submission: %s)\n' \
"${status:-unknown}" "${submission_id:-unknown}" >&2
if [[ -n $submission_id ]]; then
mkdir -p "$(dirname "$log_output")"
if xcrun notarytool log "$submission_id" "$log_output" \
--keychain-profile "$keychain_profile"; then
printf '%s\n' 'Apple notarization log:' >&2
cat "$log_output" >&2
else
printf 'error: could not retrieve the Apple notarization log\n' >&2
fi
fi
exit 1

72
apple/scripts/package-core.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Packages the prebuilt Apple core into a single zip + checksum, for attaching to
# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core
# instead of installing Rust and running build-core.sh. Run AFTER the core exists
# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`).
#
# The bundle carries both build outputs of build-core.sh:
# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module)
# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not
# part of the xcframework, so it must ship alongside)
#
# Produces (under apple/dist):
# VnidropCore-<version>.zip
# VnidropCore-<version>.zip.sha256 (sha256sum(1)/shasum-compatible format)
#
# Zip layout (root):
# vnidrop.xcframework/
# Vnidrop.swift
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
PKG_DIR="$APPLE_DIR/VnidropCore"
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
DIST_DIR="$APPLE_DIR/dist"
VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)"
NAME="VnidropCore-$VERSION"
ZIP="$DIST_DIR/$NAME.zip"
CHECKSUM="$ZIP.sha256"
[ -d "$XCFRAMEWORK" ] || {
echo "error: missing xcframework: $XCFRAMEWORK" >&2
echo " build the core first (apple/scripts/build-core.sh)." >&2
exit 1
}
[ -f "$BINDINGS" ] || {
echo "error: missing generated bindings: $BINDINGS" >&2
echo " build the core first (apple/scripts/build-core.sh)." >&2
exit 1
}
mkdir -p "$DIST_DIR"
rm -f "$ZIP" "$CHECKSUM"
# Stage a clean tree so the zip root holds exactly the two payloads (no absolute
# paths or stray parent directories leak into the archive).
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework"
cp "$BINDINGS" "$STAGE/Vnidrop.swift"
# -X drops extra file attributes for a stabler archive across machines.
( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift )
# sha256sum on Linux; shasum -a 256 on macOS. Both emit "<hash> <name>", which
# `sha256sum --check` (used by assemble-release.sh) accepts.
(
cd "$DIST_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$NAME.zip" > "$NAME.zip.sha256"
else
shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256"
fi
)
echo "==> Packaged prebuilt core"
echo " zip: $ZIP"
echo " checksum: $CHECKSUM"

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
printf 'Usage: %s <app-bundle> <signing-identity> <entitlements>\n' "$0" >&2
exit 2
fi
app=$1
signing_identity=$2
entitlements=$3
[[ -d $app ]] || {
printf 'error: exported app bundle does not exist: %s\n' "$app" >&2
exit 1
}
[[ -n $signing_identity ]] || {
printf 'error: signing identity is empty\n' >&2
exit 1
}
[[ -f $entitlements ]] || {
printf 'error: entitlements file does not exist: %s\n' "$entitlements" >&2
exit 1
}
codesign \
--force \
--sign "$signing_identity" \
--options runtime \
--timestamp \
--entitlements "$entitlements" \
"$app"
codesign --verify --deep --strict --verbose=2 "$app"
signature_details="$(codesign --display --verbose=4 "$app" 2>&1)"
printf '%s\n' "$signature_details"
printf '%s\n' "$signature_details" |
grep -Eq 'flags=.*\(runtime([^)]*)?\)' || {
printf 'error: exported app signature does not enable the hardened runtime\n' >&2
exit 1
}

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
notarize="$script_dir/../notarize.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-notarize-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin"
artifact="$scratch/VniDrop.dmg"
calls="$scratch/calls.txt"
log_output="$scratch/notary/notary-log.json"
printf 'dmg\n' > "$artifact"
cat > "$scratch/bin/xcrun" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$FAKE_NOTARY_CALLS"
if [[ $1 == notarytool && $2 == submit ]]; then
case "${FAKE_NOTARY_MODE:-accepted}" in
accepted)
printf '%s\n' \
'{"id":"11111111-1111-1111-1111-111111111111","status":"Accepted"}'
;;
invalid)
printf '%s\n' \
'{"id":"22222222-2222-2222-2222-222222222222","status":"Invalid"}'
;;
transport-error)
printf '%s\n' 'notary service unavailable' >&2
exit 1
;;
esac
elif [[ $1 == notarytool && $2 == log ]]; then
mkdir -p "$(dirname "$4")"
printf '%s\n' \
'{"status":"Invalid","issues":[{"message":"The signature is invalid."}]}' \
> "$4"
else
printf 'unexpected xcrun invocation: %s\n' "$*" >&2
exit 1
fi
SCRIPT
chmod +x "$scratch/bin/xcrun"
PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=accepted \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null
[[ ! -e $log_output ]]
[[ $(grep -c '^notarytool submit ' "$calls") -eq 1 ]]
if grep -q '^notarytool log ' "$calls"; then
printf 'Accepted submissions must not request a rejection log\n' >&2
exit 1
fi
: > "$calls"
if PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=invalid \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
printf 'Invalid notarization must fail\n' >&2
exit 1
fi
grep -F '"The signature is invalid."' "$log_output" >/dev/null
grep -F \
'notarytool log 22222222-2222-2222-2222-222222222222' \
"$calls" >/dev/null
: > "$calls"
rm -f "$log_output"
if PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=transport-error \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
printf 'Notary transport errors must fail\n' >&2
exit 1
fi
[[ ! -e $log_output ]]
if grep -q '^notarytool log ' "$calls"; then
printf 'A submission without an ID cannot request a rejection log\n' >&2
exit 1
fi
printf 'Notarization helper tests passed.\n'

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sign_exported_app="$script_dir/../sign-exported-app.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-codesign-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin" "$scratch/VniDrop.app/Contents/MacOS"
app="$scratch/VniDrop.app"
entitlements="$scratch/VniDropDirect.entitlements"
calls="$scratch/calls.txt"
printf '<plist><dict/></plist>\n' > "$entitlements"
printf 'binary\n' > "$app/Contents/MacOS/VniDrop"
cat > "$scratch/bin/codesign" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$FAKE_CODESIGN_CALLS"
case " $* " in
*" --display "*)
if [[ ${FAKE_CODESIGN_MODE:-runtime} == missing-runtime ]]; then
printf '%s\n' \
'CodeDirectory v=20500 size=123 flags=0x0(none) hashes=1+0 location=embedded' \
>&2
else
printf '%s\n' \
'CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=1+0 location=embedded' \
>&2
fi
;;
*" --verify "*)
if [[ ${FAKE_CODESIGN_MODE:-runtime} == verify-error ]]; then
printf '%s\n' 'invalid signature' >&2
exit 1
fi
;;
esac
SCRIPT
chmod +x "$scratch/bin/codesign"
PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null
grep -F -- \
'--force --sign Developer ID Application: Example (ABCDEFGHIJ) --options runtime --timestamp --entitlements' \
"$calls" >/dev/null
grep -F -- '--verify --deep --strict --verbose=2' "$calls" >/dev/null
grep -F -- '--display --verbose=4' "$calls" >/dev/null
if PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
FAKE_CODESIGN_MODE=missing-runtime \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null 2>&1; then
printf 'A signature without the hardened runtime must fail\n' >&2
exit 1
fi
if PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
FAKE_CODESIGN_MODE=verify-error \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null 2>&1; then
printf 'Signature verification errors must fail\n' >&2
exit 1
fi
printf 'Exported app signing tests passed.\n'

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
@@ -13,3 +15,67 @@ plugins {
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}
val versionFile = layout.projectDirectory.file("version.properties")
val versionProperties = Properties().apply {
versionFile.asFile.inputStream().use(::load)
}
fun requiredVersionProperty(name: String): String =
versionProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: error("Missing $name in ${versionFile.asFile}")
fun canonicalInteger(name: String, value: String, range: LongRange): Long {
require(value.matches(Regex("0|[1-9][0-9]*"))) {
"$name must be a canonical non-negative integer"
}
val number = value.toLongOrNull()
require(number != null && number in range) {
"$name must be between ${range.first} and ${range.last}"
}
return number
}
val productVersion = requiredVersionProperty("PRODUCT_VERSION")
val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
.matchEntire(productVersion)
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
require(productVersionParts[0] <= 2099 && productVersionParts.drop(1).all { it <= 999 }) {
"PRODUCT_VERSION must use a major no greater than 2099 and minor/patch no greater than 999"
}
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
"RELEASE_CHANNEL contains unsupported characters"
}
val androidVersionCode =
(productVersionParts[0] * 1_000_000L + productVersionParts[1] * 1_000L + productVersionParts[2])
.also { require(it in 1L..2_100_000_000L) { "Derived Android version code is out of range" } }
.toInt()
val windowsVersionEpoch = canonicalInteger(
"WINDOWS_VERSION_EPOCH",
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
1L..65535L,
)
val windowsMajor = productVersionParts[0] + windowsVersionEpoch
require(windowsMajor <= 65535) {
"Derived Windows package major exceeds 65535"
}
val windowsPackageVersion =
"$windowsMajor.${productVersionParts[1]}.${productVersionParts[2]}.0"
extra["vnidrop.productVersion"] = productVersion
extra["vnidrop.releaseChannel"] = releaseChannel
extra["vnidrop.androidVersionCode"] = androidVersionCode
extra["vnidrop.windowsPackageVersion"] = windowsPackageVersion
tasks.register("verifyVersion") {
group = "verification"
description = "Validates the canonical cross-platform application version."
inputs.file(versionFile)
inputs.property("productVersion", productVersion)
inputs.property("releaseChannel", releaseChannel)
inputs.property("androidVersionCode", androidVersionCode)
inputs.property("windowsPackageVersion", windowsPackageVersion)
}

View File

@@ -1,5 +1,5 @@
# Default command configuration. Override locally in the ignored
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0).
# Default command configuration. Override local tool paths in the ignored
# config.override.mk or on the command line.
ifeq ($(OS),Windows_NT)
HOST_OS := windows
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
OPEN ?= open
POWERSHELL ?= pwsh
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties)
override VERSION := $(shell $(ROOT)/packaging/version/resolve-version.sh product)
APPLE_PROFILE ?= debug
APPLE_CONFIGURATION ?= Debug
APPLE_DESTINATION ?=

31
crates/vnidrop/build.rs Normal file
View File

@@ -0,0 +1,31 @@
use std::{env, fs, path::PathBuf};
fn main() {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let version_file = manifest_dir.join("../../version.properties");
println!("cargo:rerun-if-changed={}", version_file.display());
let contents = fs::read_to_string(&version_file)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", version_file.display()));
let versions: Vec<_> = contents
.lines()
.filter_map(|line| line.strip_prefix("PRODUCT_VERSION="))
.collect();
assert_eq!(
versions.len(),
1,
"{} must contain exactly one PRODUCT_VERSION",
version_file.display()
);
let version = versions[0];
let components: Vec<_> = version.split('.').collect();
assert!(
components.len() == 3
&& components.iter().all(|component| {
component.parse::<u16>().is_ok()
&& (component == &"0" || !component.starts_with('0'))
}),
"PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
);
println!("cargo:rustc-env=VNIDROP_PRODUCT_VERSION={version}");
}

View File

@@ -8,7 +8,10 @@ use uuid::Uuid;
use crate::{
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
event_hub::EventHub,
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
RequestTransfer,
},
repository::{ReceiverRequestInsert, Repository},
transfer_state::ReceiverRequestStatus,
util::now_ms,
@@ -70,6 +73,46 @@ impl ApprovalService {
}
}
pub(crate) async fn fail_delivery(
&self,
remote_endpoint_id: String,
receipt: DeliveryFailureReceipt,
) -> DeliveryReceiptResponse {
let token_hash = receipt_token_hash(&receipt.token);
match self
.repository
.fail_receiver_delivery(
&receipt.request_id,
receipt.transfer_id,
&remote_endpoint_id,
&token_hash,
&receipt.reason,
)
.await
{
Ok(()) => {
self.event_hub.emit_transfer(
receipt.transfer_id,
"send",
"delivery",
"receiver-failed",
json!({
"request_id": receipt.request_id,
"remote_endpoint_id": remote_endpoint_id,
"reason": receipt.reason,
}),
);
DeliveryReceiptResponse::Recorded
}
Err(error) => {
tracing::warn!(%error, "rejected receiver delivery failure");
DeliveryReceiptResponse::Rejected {
reason: "invalid-receipt".to_string(),
}
}
}
}
pub(crate) fn new(
repository: Repository,
event_hub: Arc<EventHub>,

View File

@@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService {
.await;
let _ = tx.send(response).await;
}
HandshakeMessage::ReportDeliveryFailure(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.approval
.fail_delivery(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
}
}
@@ -98,7 +106,7 @@ impl HandshakeClient {
transfer_name: metadata.transfer_name.clone(),
receiver_name: receiver_name.map(ToOwned::to_owned),
receiver_device_name: None,
app_version: env!("CARGO_PKG_VERSION").to_string(),
app_version: env!("VNIDROP_PRODUCT_VERSION").to_string(),
})
.await
}
@@ -109,6 +117,13 @@ impl HandshakeClient {
) -> Result<DeliveryReceiptResponse, irpc::Error> {
self.inner.rpc(receipt).await
}
pub(crate) async fn report_delivery_failure(
&self,
receipt: DeliveryFailureReceipt,
) -> Result<DeliveryReceiptResponse, irpc::Error> {
self.inner.rpc(receipt).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt {
pub(crate) token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliveryFailureReceipt {
pub(crate) request_id: String,
pub(crate) transfer_id: u64,
pub(crate) token: String,
pub(crate) reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum DeliveryReceiptResponse {
Recorded,
@@ -153,4 +176,6 @@ enum HandshakeProtocol {
RequestTransfer(RequestTransfer),
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
ReportDelivery(DeliveryReceipt),
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
ReportDeliveryFailure(DeliveryFailureReceipt),
}

View File

@@ -20,7 +20,7 @@ use crate::{
util::now_ms,
};
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_VERSION: i64 = 7;
#[derive(Debug, Clone)]
pub(crate) struct Repository {
@@ -86,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt {
pub(crate) request_id: String,
pub(crate) sender_transfer_id: u64,
pub(crate) token: String,
pub(crate) failure_reason: Option<String>,
}
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
@@ -94,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
pub(crate) request_id: &'a str,
pub(crate) sender_transfer_id: u64,
pub(crate) token: &'a str,
pub(crate) failure_reason: Option<&'a str>,
}
impl Repository {
@@ -294,12 +296,24 @@ impl Repository {
sender_blob_ticket TEXT NOT NULL,
sender_transfer_id INTEGER NOT NULL,
token TEXT NOT NULL,
failure_reason TEXT,
created_at INTEGER NOT NULL
);
"#,
)
.execute(&self.pool)
.await?;
let receipt_columns = sqlx::query("PRAGMA table_info(pending_delivery_receipts)")
.fetch_all(&self.pool)
.await?;
if !receipt_columns
.iter()
.any(|row| row.get::<String, _>(1) == "failure_reason")
{
sqlx::query("ALTER TABLE pending_delivery_receipts ADD COLUMN failure_reason TEXT")
.execute(&self.pool)
.await?;
}
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool)
@@ -561,13 +575,14 @@ impl Repository {
r#"
INSERT INTO pending_delivery_receipts (
request_id, local_transfer_id, sender_blob_ticket,
sender_transfer_id, token, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
sender_transfer_id, token, failure_reason, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(request_id) DO UPDATE SET
local_transfer_id = excluded.local_transfer_id,
sender_blob_ticket = excluded.sender_blob_ticket,
sender_transfer_id = excluded.sender_transfer_id,
token = excluded.token
token = excluded.token,
failure_reason = excluded.failure_reason
"#,
)
.bind(receipt.request_id)
@@ -575,6 +590,7 @@ impl Repository {
.bind(receipt.sender_blob_ticket)
.bind(to_db_id(receipt.sender_transfer_id)?)
.bind(receipt.token)
.bind(receipt.failure_reason)
.bind(now_ms())
.execute(&mut *transaction)
.await?;
@@ -582,13 +598,46 @@ impl Repository {
Ok(())
}
pub(crate) async fn queue_failed_delivery_receipt(
&self,
receipt: PendingDeliveryReceiptInsert<'_>,
) -> Result<()> {
let Some(reason) = receipt.failure_reason else {
anyhow::bail!("failed delivery receipt requires a reason");
};
sqlx::query(
r#"
INSERT INTO pending_delivery_receipts (
request_id, local_transfer_id, sender_blob_ticket,
sender_transfer_id, token, failure_reason, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(request_id) DO UPDATE SET
local_transfer_id = excluded.local_transfer_id,
sender_blob_ticket = excluded.sender_blob_ticket,
sender_transfer_id = excluded.sender_transfer_id,
token = excluded.token,
failure_reason = excluded.failure_reason
"#,
)
.bind(receipt.request_id)
.bind(to_db_id(receipt.local_transfer_id)?)
.bind(receipt.sender_blob_ticket)
.bind(to_db_id(receipt.sender_transfer_id)?)
.bind(receipt.token)
.bind(reason)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn list_pending_delivery_receipts(
&self,
) -> Result<Vec<PendingDeliveryReceipt>> {
let rows = sqlx::query(
r#"
SELECT local_transfer_id, sender_blob_ticket, request_id,
sender_transfer_id, token
sender_transfer_id, token, failure_reason
FROM pending_delivery_receipts
ORDER BY created_at ASC
"#,
@@ -603,6 +652,7 @@ impl Repository {
request_id: row.get("request_id"),
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
token: row.get("token"),
failure_reason: row.get("failure_reason"),
})
.collect())
}
@@ -883,6 +933,56 @@ impl Repository {
}
}
pub(crate) async fn fail_receiver_delivery(
&self,
id: &str,
transfer_id: u64,
remote_endpoint_id: &str,
token_hash: &str,
reason: &str,
) -> Result<()> {
let result = sqlx::query(
r#"
UPDATE receiver_requests
SET status = 'failed', reason = ?1
WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5
AND status = 'accepted'
"#,
)
.bind(reason)
.bind(id)
.bind(to_db_id(transfer_id)?)
.bind(remote_endpoint_id)
.bind(token_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 1 {
return Ok(());
}
let already_recorded = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1 FROM receiver_requests
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
AND receipt_token_hash = ?4 AND status = 'failed'
)
"#,
)
.bind(id)
.bind(to_db_id(transfer_id)?)
.bind(remote_endpoint_id)
.bind(token_hash)
.fetch_one(&self.pool)
.await?
.get::<i64, _>(0)
!= 0;
if already_recorded {
Ok(())
} else {
anyhow::bail!("delivery failure did not match an accepted receiver request")
}
}
pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> {
let result = sqlx::query(
r#"

View File

@@ -4,7 +4,9 @@ use serde_json::json;
use super::{filter_peer_addr_for_relay_mode, CoreInner};
use crate::{
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
},
repository::PendingDeliveryReceipt,
ticket::parse_persisted_sender_address,
};
@@ -96,13 +98,29 @@ impl CoreInner {
}
};
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
let receipt = DeliveryReceipt {
request_id: pending.request_id.clone(),
transfer_id: pending.sender_transfer_id,
token: pending.token,
};
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
{
let request_id = pending.request_id.clone();
let response = tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, async {
if let Some(reason) = pending.failure_reason {
client
.report_delivery_failure(DeliveryFailureReceipt {
request_id,
transfer_id: pending.sender_transfer_id,
token: pending.token,
reason,
})
.await
} else {
client
.report_delivery(DeliveryReceipt {
request_id,
transfer_id: pending.sender_transfer_id,
token: pending.token,
})
.await
}
})
.await;
match response {
Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
if let Err(error) = self
.repository

View File

@@ -1,7 +1,7 @@
use std::{
io,
path::{Path, PathBuf},
sync::Arc,
sync::{Arc, Mutex},
};
use anyhow::{Context, Result};
@@ -222,6 +222,10 @@ impl CoreInner {
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
.await
.map_err(VnidropError::repository)?;
let persisted_sender_address =
encode_persisted_sender_address(parsed.blob_ticket.addr())
.context("failed to encode sender address for delivery receipt")?;
let delivery_receipt = Arc::new(Mutex::new(None));
// Cancellation is cooperative: it stops our receive future and marks
// local state while lower-level Iroh work unwinds naturally.
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
@@ -237,7 +241,14 @@ impl CoreInner {
);
let (result, cancelled) = tokio::select! {
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => {
result = self.receive_inner(
transfer_id,
parsed,
target,
receiver_name,
persisted_sender_address.clone(),
delivery_receipt.clone(),
) => {
(result.map_err(VnidropError::transfer), false)
},
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
@@ -267,6 +278,34 @@ impl CoreInner {
)
.await;
}
let receipt = delivery_receipt.lock().expect("delivery_receipt").take();
if let Some(receipt) = receipt {
let reason = if cancelled { "cancelled" } else { error.code() };
match self
.repository
.queue_failed_delivery_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: transfer_id,
sender_blob_ticket: &persisted_sender_address,
request_id: &receipt.request_id,
sender_transfer_id: receipt.transfer_id,
token: &receipt.token,
failure_reason: Some(reason),
})
.await
{
Ok(()) => self.delivery_receipt_notify.notify_one(),
Err(queue_error) => {
tracing::warn!(%queue_error, "failed to queue delivery failure receipt");
self.emit_transfer(
transfer_id,
"receive",
"delivery",
"receipt-failed",
json!({ "reason": queue_error.to_string() }),
);
}
}
}
}
result.map_err(anyhow::Error::new)
}
@@ -277,6 +316,8 @@ impl CoreInner {
parsed: ParsedTransferTicket,
target: ReceiveTarget,
receiver_name: Option<String>,
persisted_sender_address: String,
pending_delivery_receipt: Arc<Mutex<Option<DeliveryReceipt>>>,
) -> Result<()> {
if let ReceiveTarget::Directory(output_dir) = &target {
tokio::fs::create_dir_all(output_dir)
@@ -284,8 +325,6 @@ impl CoreInner {
.map_err(VnidropError::filesystem)?;
}
let sender_addr = parsed.blob_ticket.addr().clone();
let persisted_sender_address = encode_persisted_sender_address(&sender_addr)
.context("failed to encode sender address for delivery receipt")?;
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
// Every VniDrop ticket carries metadata and must complete the handshake.
@@ -297,6 +336,9 @@ impl CoreInner {
receiver_name.as_deref(),
)
.await?;
*pending_delivery_receipt
.lock()
.expect("pending_delivery_receipt") = Some(delivery_receipt.clone());
let connection = self
.endpoint
.connect(sender_addr.clone(), iroh_blobs::ALPN)
@@ -373,9 +415,14 @@ impl CoreInner {
request_id: &delivery_receipt.request_id,
sender_transfer_id: delivery_receipt.transfer_id,
token: &delivery_receipt.token,
failure_reason: None,
})
.await
.map_err(VnidropError::repository)?;
pending_delivery_receipt
.lock()
.expect("pending_delivery_receipt")
.take();
drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
self.delivery_receipt_notify.notify_one();

View File

@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 6);
assert_eq!(repository.schema_version().await.unwrap(), 7);
repository
.insert_transfer(transfer(
7,
@@ -135,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() {
request_id: "request-93",
sender_transfer_id: 39,
token: "receipt-token",
failure_reason: None,
})
.await
.unwrap();
@@ -221,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() {
assert!(requests[0].completed_at.is_some());
}
#[tokio::test]
async fn authenticated_delivery_failure_marks_accepted_receiver_failed() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-failed",
transfer_id: 78,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: None,
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-failed", ReceiverRequestStatus::Accepted, None)
.await
.unwrap();
repository
.set_receiver_receipt_token("request-failed", "token-hash")
.await
.unwrap();
repository
.fail_receiver_delivery(
"request-failed",
78,
"node-a",
"token-hash",
"destination_exists",
)
.await
.unwrap();
repository
.fail_receiver_delivery(
"request-failed",
78,
"node-a",
"token-hash",
"destination_exists",
)
.await
.unwrap();
assert!(repository
.fail_receiver_delivery(
"request-failed",
78,
"node-b",
"token-hash",
"destination_exists",
)
.await
.is_err());
let requests = repository.list_receiver_requests(78).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "failed");
assert_eq!(requests[0].reason.as_deref(), Some("destination_exists"));
}
#[tokio::test]
async fn startup_expiration_is_idempotent_for_pending_requests() {
let temp = tempfile::tempdir().unwrap();
@@ -582,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 6);
assert_eq!(repository.schema_version().await.unwrap(), 7);
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send");

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