84 Commits

Author SHA1 Message Date
877083a3ed refactor(diagnostics): remove bug report breadcrumbs 2026-08-02 19:18:18 +02:00
7cb2270d56 ci(apple): add Xcode Cloud post-clone script
Xcode Cloud only checks out the repo, so ci_post_clone.sh installs swiftlint,
xcodegen and bun, downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift)
from the matching GitHub Release asset, and generates the project via localization,
version/app config codegen and xcodegen. Rust is never built on Xcode Cloud.
2026-08-02 10:29:46 +02:00
eb8498168d fix(shared): avoid java accessor shadowing when reading app.properties
In a Gradle Kotlin DSL script `java` resolves to the Java plugin extension
accessor, so `java.util.Properties` failed script compilation with
"Unresolved reference 'util'", breaking the shared/Linux/Windows KMP jobs.
Import java.util.Properties and use it unqualified, matching the root build.
2026-08-02 10:29:45 +02:00
ac6e837560 docs: describe bug reports instead of telemetry
Update the site privacy policy (no telemetry/analytics, bug-report only, v1.2)
and the README/apple README to reflect that only user-submitted bug reports
remain.
2026-08-02 10:03:29 +02:00
3e18378610 refactor(diagnostics-api): drop telemetry and crash ingestion, keep bug reports
Remove the /v1/events and /v1/crashes routes, their normalizers and storage
paths, and simplify retention to the bugs table. Add a migration dropping the
now-unused event_batches and crashes tables, and regenerate worker types.
2026-08-02 10:03:12 +02:00
b68d338097 refactor(apple): remove diagnostics opt-in toggle, keep bug reports
Drop the Share-diagnostics preference, its Settings toggle and the
DiagnosticsBuildConfig stub. Bug reporting (NoopBugReportService) and the
diagnostics install id used for bug-report correlation are retained.
2026-08-02 10:02:49 +02:00
b8a002a2ad refactor(shared): remove telemetry and crash reporting, keep bug reports
Delete the TelemetryRecorder, CrashReporter, PendingCrashStore and platform
crash hooks along with their models, JSON encoders and the diagnostics opt-in
preference. The DiagnosticsTransport interface is narrowed to sendBugReport, and
DiagnosticsCoordinator now only wires the bug-report service and install id.

Bug reporting, the breadcrumb buffer, log redaction and the diagnostics endpoint
config are kept. Regenerate localization after dropping the diagnostics_* keys.
2026-08-02 10:02:28 +02:00
232fb125d3 feat(config): shared app.properties for app-wide constants
Add a single source of truth (root app.properties) for public app-wide
constants, injected at build time on both platforms instead of hardcoding.

- Apple: generate-appconfig.sh -> Generated/AppConfig.swift (wired into
  `make apple-app-config`), consumed as AppConfig.privacyPolicyURL.
- KMP: generateAppConfig task -> AppConfig.kt (mirrors DiagnosticsBuildConfig),
  consumed as AppConfig.PRIVACY_POLICY_URL.

Replaces the stale hardcoded privacy-policy URL on both sides with
https://vnidrop.sudosy.fr/privacy/.

Also fix the Apple release core build: disable release LTO in build-core.sh
(Cargo forbids lto in a build-override) to avoid the proc-macro
"mis-aligned LINKEDIT string pool" corruption, so release archives are
compact instead of shipping the debug core.

Update the app icon.

Tests: shell test for the generator (escaping, missing/duplicate key),
plus XCTest and jvmTest asserting the generated value matches app.properties.
2026-08-01 19:56:07 +02:00
Hammed Abass
d52ac52cea Merge pull request #40 from sudosylabs/feat/macos-approval-modal-fix
fix(apple): show macOS approval modal + publish prebuilt core bundle
2026-07-31 17:07:40 +02:00
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
0448137d84 fix(core): abort send when provider stream closes 2026-07-24 00:11:58 +02:00
4074f4bee8 feat(storage): clear inactive transfer cache 2026-07-23 22:22:30 +02:00
7cc0e825f6 fix(settings): confirm deleting all transfers 2026-07-23 19:16:11 +02:00
efb3c474d1 feat(settings): align relay and storage controls 2026-07-23 19:03:19 +02:00
7592d49a59 fix(deps): update iroh to 1.0.3 2026-07-23 16:50:27 +02:00
a0bcc5dbff feat(network): add relay connection policies 2026-07-23 15:14:03 +02:00
cbace73908 feat(network): support custom relay servers
Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
2026-07-23 14:27:40 +02:00
244 changed files with 12605 additions and 3875 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: pull_request:
paths: paths:
- "apple/**" - "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "crates/uniffi-bindgen/**" - "crates/uniffi-bindgen/**"
- "Cargo.toml" - "Cargo.toml"
@@ -18,6 +20,8 @@ on:
- master - master
paths: paths:
- "apple/**" - "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "crates/uniffi-bindgen/**" - "crates/uniffi-bindgen/**"
- "Cargo.toml" - "Cargo.toml"
@@ -65,6 +69,10 @@ jobs:
- name: Install XcodeGen - name: Install XcodeGen
run: brew 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 - name: Install Bun
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are # The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
# generated from localization/strings.json at build time, not tracked. # generated from localization/strings.json at build time, not tracked.
@@ -72,3 +80,8 @@ jobs:
- name: Build and test Apple app - name: Build and test Apple app
run: make check-apple 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: paths:
- ".github/workflows/linux-packages.yml" - ".github/workflows/linux-packages.yml"
- "packaging/linux/**" - "packaging/linux/**"
- "packaging/version/**"
- "version.properties"
- "assets/linux/**" - "assets/linux/**"
- "desktopApp/**" - "desktopApp/**"
- "shared/**" - "shared/**"
@@ -20,16 +22,8 @@ on:
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"
push: workflow_call:
tags:
- "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -88,16 +82,14 @@ jobs:
restore-keys: | restore-keys: |
linux-deb-x64-cargo-1.91.0- linux-deb-x64-cargo-1.91.0-
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Test and build Debian package - name: Test and build Debian package
run: make package-deb VERSION=${{ steps.version.outputs.app }} run: make package-deb
- name: Upload Debian artifact - name: Upload Debian artifact
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -193,16 +185,14 @@ jobs:
restore-keys: | restore-keys: |
linux-rpm-x64-cargo-1.91.0- linux-rpm-x64-cargo-1.91.0-
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Build RPM package - name: Build RPM package
run: make package-rpm VERSION=${{ steps.version.outputs.app }} run: make package-rpm
- name: Upload RPM artifact - name: Upload RPM artifact
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -220,74 +210,3 @@ jobs:
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY" echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY" echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
echo "- Build environment: Fedora 43" >> "$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.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"
@@ -19,6 +21,8 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"

View File

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

View File

@@ -5,6 +5,8 @@ on:
paths: paths:
- ".github/workflows/windows-store.yml" - ".github/workflows/windows-store.yml"
- "packaging/windows/**" - "packaging/windows/**"
- "packaging/version/**"
- "version.properties"
- "assets/windows/**" - "assets/windows/**"
- "desktopApp/**" - "desktopApp/**"
- "shared/**" - "shared/**"
@@ -17,16 +19,8 @@ on:
- "gradle/**" - "gradle/**"
- "gradlew" - "gradlew"
- "gradlew.bat" - "gradlew.bat"
push: workflow_call:
tags:
- "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -77,37 +71,13 @@ jobs:
restore-keys: | restore-keys: |
windows-x64-cargo-1.91.0- windows-x64-cargo-1.91.0-
- name: Resolve Store version - name: Resolve canonical version
id: version id: version
shell: pwsh shell: pwsh
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
$version = $env:REQUESTED_VERSION $version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
if ($env:GITHUB_REF_TYPE -eq "tag") { "app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") { "package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
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
- name: Test and build release app image - name: Test and build release app image
shell: pwsh shell: pwsh
@@ -115,7 +85,6 @@ jobs:
$arguments = @( $arguments = @(
":shared:jvmTest" ":shared:jvmTest"
":desktopApp:createReleaseDistributable" ":desktopApp:createReleaseDistributable"
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
"-Pvnidrop.desktop.rustVariant=release" "-Pvnidrop.desktop.rustVariant=release"
"-Pvnidrop.diagnostics.included=false" "-Pvnidrop.diagnostics.included=false"
"--no-daemon" "--no-daemon"
@@ -131,7 +100,6 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$arguments = @{ $arguments = @{
Version = "${{ steps.version.outputs.app }}"
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop" AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
OutputDirectory = ".\build\release\windows" OutputDirectory = ".\build\release\windows"
} }

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ config.override.mk
# Local design export scratch # Local design export scratch
output/ output/
.screenshots .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. 8. **Every bug fix includes a regression test** at the lowest layer that catches it.
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test) 9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
and fix failures before finishing. and fix failures before finishing.
10. **`localization/strings.json` is the single source of truth for all localized
strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/
values*/strings.xml`) and the Apple catalog + accessors
(`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/
L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run
src/cli.ts generate`) — never hand-edit them. To add/change a string: edit
`strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then
regenerate. A key referenced in code but only present in a generated file will
be silently dropped the next time generation runs.
--- ---
@@ -291,6 +300,8 @@ branch from updated `master`.
- Flaky multi-minute sleeps in tests - Flaky multi-minute sleeps in tests
- Unsigned commits when signing is required - Unsigned commits when signing is required
- Force-push or secret commits without explicit user direction - Force-push or secret commits without explicit user direction
- Hand-editing generated localization files (`values*/strings.xml`,
`Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json`
--- ---

389
Cargo.lock generated
View File

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

View File

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

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: 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: 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: 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: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
.PHONY: check-localization localization localization-migrate .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 .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. 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}' @grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
@printf '\nCommon variables:\n' @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_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_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override' @printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
@@ -59,7 +59,27 @@ format: ## Format Rust sources.
test: test-rust test-shared ## Run the main Rust and shared JVM test suites. 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 apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.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-generate-appconfig.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. check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check cd $(ROOT) && $(CARGO) fmt --all -- --check
@@ -113,7 +133,13 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; } @test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE) cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
apple-project: apple-core localization ## 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-app-config: ## Generate AppConfig.swift from the shared app.properties.
cd $(ROOT) && apple/scripts/generate-appconfig.sh
apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project.
cd $(ROOT)/apple && $(XCODEGEN) generate cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project. open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
@@ -122,6 +148,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). 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 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. 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; } @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" $(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"

View File

@@ -50,6 +50,42 @@ mobile networks. If a direct path cannot be established, it can forward the
same end-to-end encrypted connection through a relay. The relay forwards same end-to-end encrypted connection through a relay. The relay forwards
encrypted packets; it is not a VniDrop file store. encrypted packets; it is not a VniDrop file store.
### Custom relay servers
VniDrop uses Iroh's public relay and discovery infrastructure by default. In
**Settings → Network**, users can select one of four policies:
- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN
connections whenever possible.
- **Strict custom:** use only up to eight configured custom HTTPS relays or
direct connections. Startup reports an error if none of the custom relays can
be established.
- **Custom with direct fallback:** prefer the configured custom relays, but
continue with direct connections if they are unavailable.
- **Local only:** disable every relay and allow direct connections only,
primarily for devices on the same network.
Strict custom, custom with direct fallback, and local only never use public
relays or public discovery, including relay addresses advertised by incoming
invitations.
Applying a relay change restarts VniDrop's network engine, so active transfers
and shares must be stopped first. The app tests the new configuration and
restores the previous one if it cannot connect. Invitations created for an old
relay configuration may need to be shared again; stopped shares never expose
their stale invitations. If a long relay profile makes an invitation too large
for a QR code, use the native share action or export the invitation file.
Relay credentials embedded in URLs are deliberately rejected and bearer-token
authentication is not currently supported. A self-hosted relay must either
accept the connecting endpoints or authorize their endpoint IDs independently;
the current device ID is shown in **Settings → Network** for this purpose.
Configure the same relay profile on participating devices. A custom relay needs
a TLS certificate issued by a publicly trusted WebPKI certificate authority;
private or enterprise CAs installed only in the operating system are not used
in this version. For resilient deployments, configure at least two relays in
different failure domains.
## Why Iroh and `iroh-blobs`? ## Why Iroh and `iroh-blobs`?
VniDrop combines a networking layer with its own sharing rules: VniDrop combines a networking layer with its own sharing rules:
@@ -90,18 +126,19 @@ people, especially when using **Anyone with this transfer**.
- Safe receive destinations that do not silently overwrite existing files - Safe receive destinations that do not silently overwrite existing files
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, - Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
Windows, and Linux Windows, and Linux
- Opt-in diagnostics with transfer contents, invitations, and file paths - Strict custom HTTPS relay profiles with safe apply and rollback
excluded - Optional user-submitted bug reports with transfer contents, invitations, and
file paths excluded
## Privacy by design ## Privacy by design
- **No hosted transfer copy.** VniDrop does not upload file contents to its - **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
diagnostics service or a VniDrop storage bucket. service or a VniDrop storage bucket.
- **Encrypted in transit.** Iroh connections are authenticated and encrypted - **Encrypted in transit.** Iroh connections are authenticated and encrypted
end to end, including when a relay is needed. end to end, including when a relay is needed.
- **Local control.** Transfer history and sharing state stay on the device. - **Local control.** Transfer history and sharing state stay on the device.
- **Sensitive invitations.** An invitation can grant access, so it is - **Sensitive invitations.** An invitation can grant access, so it is
deliberately excluded from product logs and diagnostics. deliberately excluded from product logs and bug reports.
- **Explicit access.** Approval is required by default, and stopping a share - **Explicit access.** Approval is required by default, and stopping a share
removes access immediately. removes access immediately.

View File

@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
archive.getEntry(path)?.size?.takeIf { it > 0L } == null archive.getEntry(path)?.size?.takeIf { it > 0L } == null
} }
check(missing.isEmpty()) { 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) 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 { kotlin {
compilerOptions { compilerOptions {
jvmTarget = JvmTarget.JVM_11 jvmTarget = JvmTarget.JVM_11
@@ -55,12 +71,26 @@ android {
namespace = "com.vnidrop.app" namespace = "com.vnidrop.app"
compileSdk = libs.versions.android.compileSdk.get().toInt() 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 { defaultConfig {
applicationId = "com.vnidrop.app" applicationId = "com.vnidrop.app"
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = androidVersionCode
versionName = "1.0" versionName = appVersion
} }
packaging { packaging {
resources { resources {
@@ -76,6 +106,7 @@ android {
buildTypes { buildTypes {
getByName("release") { getByName("release") {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
} }
} }
compileOptions { 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/aarch64-linux-android/debug"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-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", ":shared:copyAndroidAndroidX64Debug",
) )
} }
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
dependsOn(
":shared:copyAndroidAndroidArm64Release",
":shared:copyAndroidAndroidX64Release",
)
}
} }
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") { val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {

View File

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

4
app.properties Normal file
View File

@@ -0,0 +1,4 @@
# Public, app-wide configuration shared by every platform (Apple + KMP).
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
# Injected into the apps at build time — never hardcode these values in app code.
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/

4
apple/.gitignore vendored
View File

@@ -18,3 +18,7 @@ Local.xcconfig
.swiftpm/ .swiftpm/
DerivedData/ DerivedData/
*.xcuserstate *.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-core # Rust core, Swift bindings, and XCFramework
make apple-project # generate apple/VniDrop.xcodeproj make apple-project # generate apple/VniDrop.xcodeproj
make open-apple-project # generate and open the project in Xcode 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 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 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 Use `APPLE_PROFILE=release` to request a release Rust core, or set
`APPLE_DESTINATION` to override the automatically selected iOS simulator. `APPLE_DESTINATION` to override the automatically selected iOS simulator.
Code signing is disabled for the app and test targets; local and CI builds do Code signing is disabled for the app and test targets; local and CI builds do
@@ -90,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications: Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios` - Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
dependency); swap in `lottie-ios` if exact-parity animation is required. dependency); swap in `lottie-ios` if exact-parity animation is required.
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind - Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI a real transport lands in a later phase. There is no telemetry or crash
hides the diagnostics toggle when not compiled in. auto-reporting.
``` ```

View File

@@ -0,0 +1,39 @@
import XCTest
@testable import VniDrop
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
/// exposes the expected, well-formed values to the app.
final class AppConfigTests: XCTestCase {
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
let url = AppConfig.privacyPolicyURL
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
}
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
// Cross-check the generated constant against the single source of truth so a
// broken generator (or drift) is caught, not just a hardcoded copy.
let expected = try Self.privacyURLFromAppProperties()
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
}
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
/// from this source file's location to the repository root.
private static func privacyURLFromAppProperties() throws -> String {
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
for _ in 0..<8 {
let candidate = dir.appendingPathComponent("app.properties")
if FileManager.default.fileExists(atPath: candidate.path) {
let contents = try String(contentsOf: candidate, encoding: .utf8)
for line in contents.split(whereSeparator: \.isNewline) {
if line.hasPrefix("PRIVACY_POLICY_URL=") {
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
}
}
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
}
dir.deleteLastPathComponent()
}
throw XCTSkip("app.properties not found from \(#filePath)")
}
}

View File

@@ -20,6 +20,18 @@ final class AppModelTests: XCTestCase {
_ = makeModel(core, preferences: Fixtures.preferences()) _ = makeModel(core, preferences: Fixtures.preferences())
await waitUntil { core.state.isInitialized } await waitUntil { core.state.isInitialized }
XCTAssertTrue(core.state.isInitialized) XCTAssertTrue(core.state.isInitialized)
XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic])
}
func testInitializesCoreWithSavedCustomRelayConfiguration() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
preferences.setRelayConfiguration(configuration)
_ = makeModel(core, preferences: preferences)
await waitUntil { core.state.isInitialized }
XCTAssertEqual(core.initializedNetworkConfigurations, [configuration])
} }
func testSelectDestination() { func testSelectDestination() {

View File

@@ -15,10 +15,11 @@ final class AppPreferencesRepositoryTests: XCTestCase {
) )
} }
func testFallbacksWhenEmpty() { func testMissingRelayProfileDefaultsToAutomatic() {
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
XCTAssertEqual(repo.preferences.username, "Default") XCTAssertEqual(repo.preferences.username, "Default")
XCTAssertEqual(repo.preferences.themeMode, .system) XCTAssertEqual(repo.preferences.themeMode, .system)
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
} }
func testValuesPersistAndReload() { func testValuesPersistAndReload() {
@@ -28,6 +29,10 @@ final class AppPreferencesRepositoryTests: XCTestCase {
repo.setUsername("Bob") repo.setUsername("Bob")
repo.setThemeMode(.dark) repo.setThemeMode(.dark)
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
repo.setRelayConfiguration(RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
))
// A fresh repository over the same store reflects the persisted values. // A fresh repository over the same store reflects the persisted values.
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
@@ -35,6 +40,40 @@ final class AppPreferencesRepositoryTests: XCTestCase {
XCTAssertEqual(reloaded.preferences.themeMode, .dark) XCTAssertEqual(reloaded.preferences.themeMode, .dark)
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
))
XCTAssertNotNil(store.data(forKey: "relay_configuration"))
XCTAssertNil(store.object(forKey: "relay_mode"))
XCTAssertNil(store.object(forKey: "relay_urls"))
}
func testCorruptedRelayProfileFailsClosed() {
let store = defaults()
store.set(Data("{".utf8), forKey: "relay_configuration")
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
XCTAssertEqual(
repo.preferences.relayConfiguration,
RelayConfiguration(mode: .strictCustom, relayURLs: [])
)
}
func testUnknownRelayModeFailsClosed() {
let store = defaults()
store.set(
Data(#"{"mode":"future-mode","relayURLs":["https://relay.example"]}"#.utf8),
forKey: "relay_configuration"
)
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
XCTAssertEqual(
repo.preferences.relayConfiguration,
RelayConfiguration(mode: .strictCustom, relayURLs: [])
)
} }
func testResetReceiveFolderRestoresFallback() { func testResetReceiveFolderRestoresFallback() {

View File

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

View File

@@ -25,6 +25,8 @@ final class FakeCoreGateway: CoreGateway {
var cancelResult: Result<Void, Error> = .success(()) var cancelResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(()) var deleteResult: Result<Void, Error> = .success(())
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0) var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
var initializeResult: Result<Void, Error> = .success(())
var initializeResults: [Result<Void, Error>] = []
// Recorded calls // Recorded calls
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = [] private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
@@ -35,11 +37,18 @@ final class FakeCoreGateway: CoreGateway {
private(set) var lastReceiveTicket: String? private(set) var lastReceiveTicket: String?
private(set) var lastReceiveReceiverName: String? private(set) var lastReceiveReceiverName: String?
private(set) var lastShareAccessPolicy: ShareAccessPolicy? private(set) var lastShareAccessPolicy: ShareAccessPolicy?
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
func setState(_ state: CoreState) { stateSubject.send(state) } func setState(_ state: CoreState) { stateSubject.send(state) }
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
func initialize(appDataDir: String) async -> Result<Void, Error> { func initialize(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
initializedNetworkConfigurations.append(networkConfiguration)
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
guard case .success = result else { return result }
var s = stateSubject.value var s = stateSubject.value
s.isInitialized = true s.isInitialized = true
stateSubject.send(s) stateSubject.send(s)

View File

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

View File

@@ -58,4 +58,25 @@ final class SendModelTests: XCTestCase {
let response = core.responses.first { $0.id == "req-1" } let response = core.responses.first { $0.id == "req-1" }
XCTAssertEqual(response?.accepted, false) XCTAssertEqual(response?.accepted, false)
} }
func testOnlyActiveShareExposesStoredInvitationTicket() {
XCTAssertEqual(
Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation,
.ready("ticket")
)
XCTAssertEqual(
Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation,
.preparing
)
for status in [TransferStatus.stopped, .failed, .cancelled, .done] {
XCTAssertEqual(
Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation,
.unavailable
)
}
}
func testOversizedInvitationReportsQRCodeUnavailable() {
XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000)))
}
} }

View File

@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
preferences: preferences, preferences: preferences,
notifications: LocalNotificationService(), notifications: LocalNotificationService(),
messages: UiMessageController(), messages: UiMessageController(),
bugReports: NoopBugReportService(), bugReports: NoopBugReportService()
diagnosticsIncluded: false
) )
} }
@@ -42,4 +41,107 @@ final class SettingsModelTests: XCTestCase {
await waitUntil { core.deletedTransfers.count == 2 } await waitUntil { core.deletedTransfers.count == 2 }
XCTAssertEqual(Set(core.deletedTransfers), [2, 3]) XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
} }
func testNetworkSettingsExposeCurrentEndpointId() {
let core = FakeCoreGateway()
let model = makeModel(core, preferences: Fixtures.preferences())
core.setState(CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0)
))
XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist")
}
func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
model.setRelayMode(.strictCustom)
model.setRelayURL(" https://relay.example/ ", at: 0)
model.applyRelayConfiguration()
await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom }
let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
XCTAssertEqual(preferences.preferences.relayConfiguration, expected)
XCTAssertEqual(core.initializedNetworkConfigurations, [expected])
XCTAssertFalse(model.state.relayConfigurationIsDirty)
}
func testApplyingAutomaticRetainsLastCustomRelayURLs() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let relayURLs = ["https://relay.example", "https://backup.example"]
preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs))
let model = makeModel(core, preferences: preferences)
model.setRelayMode(.automatic)
model.applyRelayConfiguration()
await waitUntil { preferences.preferences.relayConfiguration.mode == .automatic }
XCTAssertEqual(preferences.preferences.relayConfiguration.relayURLs, relayURLs)
XCTAssertEqual(core.initializedNetworkConfigurations, [
RelayConfiguration(mode: .automatic, relayURLs: relayURLs),
])
model.setRelayMode(.strictCustom)
XCTAssertEqual(model.state.relayURLs, relayURLs)
}
func testApplyRelayIsBlockedWhileShareIsActive() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
core.setState(CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1)
))
model.setRelayMode(.strictCustom)
model.setRelayURL("https://relay.example", at: 0)
model.applyRelayConfiguration()
await Task.yield()
XCTAssertTrue(core.initializedNetworkConfigurations.isEmpty)
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
}
func testRepositoryActiveWorkRejectionDoesNotAttemptRollback() async {
let core = FakeCoreGateway()
core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork)
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
model.setRelayMode(.strictCustom)
model.setRelayURL(attempted.relayURLs[0], at: 0)
model.applyRelayConfiguration()
await waitUntil {
core.initializedNetworkConfigurations.count == 1 && !model.state.isApplyingRelayConfiguration
}
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted])
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertTrue(model.state.hasActiveNetworkWork)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
}
func testFailedRelayApplyRollsBackWithoutPersisting() async {
let core = FakeCoreGateway()
core.initializeResults = [.failure(TestError.unimplemented), .success(())]
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
model.setRelayMode(.strictCustom)
model.setRelayURL(attempted.relayURLs[0], at: 0)
model.applyRelayConfiguration()
await waitUntil { core.initializedNetworkConfigurations.count == 2 }
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted, .automatic])
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_failed")
}
} }

View File

@@ -41,4 +41,11 @@ final class TransferNotificationTests: XCTestCase {
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)] let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty) 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() { func testErrorSuppressesUserCancellation() {
let c = UiMessageController() let c = UiMessageController()
c.error(InvitationError.message("QR scanning was cancelled")) c.error(InvitationError.cancelled)
XCTAssertNil(c.current) // cancellations are swallowed XCTAssertNil(c.current) // cancellations are swallowed
} }
func testErrorShowsNonCancellation() { func testErrorShowsNonCancellation() {
let c = UiMessageController() let c = UiMessageController()
c.error(InvitationError.message("The transfer was refused")) c.error(InvitationError.raw("The transfer was refused"))
XCTAssertEqual(c.current?.tone, .error) XCTAssertEqual(c.current?.tone, .error)
} }
} }
@@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase {
final class UserFacingErrorTests: XCTestCase { final class UserFacingErrorTests: XCTestCase {
func testIsUserCancellation() { func testIsUserCancellation() {
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
} }
func testToUiTextMapsKnownReasons() { func testToUiTextMapsKnownReasons() {
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) // Typed cases map directly at the UI boundary.
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty)) XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
XCTAssertEqual(InvitationError.message("Camera access is required").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() { func testToUiTextFallsBackToGeneric() {
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
} }
func testToUiTextMapsTypedTransferFailures() { func testToUiTextMapsTypedTransferFailures() {

View File

@@ -13,6 +13,7 @@ final class AppGraph: ObservableObject {
let filePreviewRepository: FilePreviewRepository let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) { init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies self.dependencies = dependencies
@@ -23,8 +24,7 @@ final class AppGraph: ObservableObject {
fallback: AppPreferencesDefaults( fallback: AppPreferencesDefaults(
username: dependencies.environment.defaultUsername, username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(), receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system, themeMode: .system
diagnosticsEnabled: false
) )
) )
self.approvalCoordinator = ApprovalCoordinator( self.approvalCoordinator = ApprovalCoordinator(
@@ -39,6 +39,7 @@ final class AppGraph: ObservableObject {
visibility: visibility, visibility: visibility,
messages: messages messages: messages
) )
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name]) AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
} }

View File

@@ -9,8 +9,6 @@ struct RootView: View {
@StateObject private var sendModel: SendModel @StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel @StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel @StateObject private var settingsModel: SettingsModel
@ObservedObject private var messages: UiMessageController
@ObservedObject private var approvals: ApprovalCoordinator
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@@ -46,8 +44,6 @@ struct RootView: View {
messages: graph.messages, messages: graph.messages,
bugReports: NoopBugReportService() bugReports: NoopBugReportService()
)) ))
messages = graph.messages
approvals = graph.approvalCoordinator
} }
var body: some View { var body: some View {
@@ -56,13 +52,26 @@ struct RootView: View {
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark) let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack { ZStack {
navigation(windowClass: windowClass) navigation(windowClass: windowClass)
SnackbarHost(controller: messages) // Observe the coordinator/messages from the *persisted* `graph`
ApprovalModalHost( // StateObject. Deriving them in `init` bound the view to a throwaway
state: approvals.state, // AppGraph rebuilt on every re-init, whose coordinator never receives
onAccept: approvals.accept, // core events so the approval modal never appeared.
onRefuse: approvals.refuse 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) .vniDropTheme(isDark: isDark)
.preferredColorScheme(appModel.themeMode.preferredColorScheme) .preferredColorScheme(appModel.themeMode.preferredColorScheme)
.environment(\.vniColors, isDark ? .dark : .light) .environment(\.vniColors, isDark ? .dark : .light)
@@ -73,23 +82,23 @@ struct RootView: View {
switch phase { switch phase {
case .active: case .active:
graph.visibility.setForeground(true) graph.visibility.setForeground(true)
graph.backgroundActivity.didBecomeForeground()
settingsModel.refreshNotificationPermission() settingsModel.refreshNotificationPermission()
// Reconcile against the durable snapshot: while the window was // Reconcile against the durable snapshot: while the window was
// unfocused/occluded (common on macOS) live events may not have // unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale. // rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() } 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) graph.visibility.setForeground(false)
@unknown default: @unknown default:
break 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) #if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so // macOS keeps `scenePhase == .active` even when the app loses focus, so
// drive foreground/background off NSApplication's active state instead // drive foreground/background off NSApplication's active state instead
@@ -183,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) #if os(iOS)
import UIKit import UIKit
#else #else

View File

@@ -8,6 +8,10 @@ private let mainWindowId = "main"
@main @main
struct VniDropApp: App { struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController() @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 { var body: some Scene {
#if os(macOS) #if os(macOS)
@@ -18,6 +22,11 @@ struct VniDropApp: App {
.ignoresSafeArea() .ignoresSafeArea()
.onOpenURL(perform: openInvitation) .onOpenURL(perform: openInvitation)
} }
#if DIRECT_DISTRIBUTION
.commands {
UpdatesCommands(controller: updater)
}
#endif
#else #else
WindowGroup(id: mainWindowId) { WindowGroup(id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations)) RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))

View File

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

@@ -24,7 +24,7 @@ protocol CoreGateway: AnyObject {
/// Coalesced change hints emitted by the event sink. /// Coalesced change hints emitted by the event sink.
var signals: AnyPublisher<CoreSignal, Never> { get } var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String) async -> Result<Void, Error> func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func shutdown() func shutdown()
func shareSources( func shareSources(
_ sources: [ShareSource], _ sources: [ShareSource],

View File

@@ -134,6 +134,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
case refused case refused
case expired case expired
case completed case completed
case failed
case unknown case unknown
} }

View File

@@ -2,6 +2,65 @@ import Foundation
import Combine import Combine
@preconcurrency import VnidropCore @preconcurrency import VnidropCore
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
case transitionInProgress
case activeNetworkWork
var errorDescription: String? {
switch self {
case .transitionInProgress: return "A network restart is already in progress."
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
}
}
}
enum CoreNetworkLifecycle {
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
guard activeTransfers == 0, activeShares == 0 else {
throw CoreNetworkLifecycleError.activeNetworkWork
}
}
}
protocol CoreBindingFactory: Sendable {
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
}
struct NativeCoreBindingFactory: CoreBindingFactory {
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
let nativeConfiguration: CoreNetworkConfig
switch networkConfiguration.mode {
case .automatic:
nativeConfiguration = defaultCoreNetworkConfig()
case .strictCustom:
nativeConfiguration = CoreNetworkConfig(
mode: .strictCustom,
relayUrls: networkConfiguration.relayURLs
)
case .customWithDirectFallback:
nativeConfiguration = CoreNetworkConfig(
mode: .customWithDirectFallback,
relayUrls: networkConfiguration.relayURLs
)
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
networkConfig: nativeConfiguration
)
}
}
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the /// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
/// generated UniFFI records into app domain models, publishes an observable /// generated UniFFI records into app domain models, publishes an observable
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink. /// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
@@ -17,28 +76,65 @@ final class CoreRepository: ObservableObject, CoreGateway {
/// Coalesced change hints; subscribe to react to approval/history/transfer changes. /// Coalesced change hints; subscribe to react to approval/history/transfer changes.
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() } var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Set on the main actor (initialize/shutdown) but read from `queue` inside // Initialization swaps happen on `queue`; shutdown and snapshot reads may also
// `runCore`; the underlying core is internally synchronized, so this crossing // access the handle from the main actor. The underlying core is internally
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6. // synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
private nonisolated(unsafe) var core: VnidropCore? private nonisolated(unsafe) var core: VnidropCore?
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
// and transition flag drive relay-aware (re)initialization.
private let dispatcher = CoreDispatcher() private let dispatcher = CoreDispatcher()
private let coreFactory: any CoreBindingFactory
private var isNetworkTransitionInProgress = false
private lazy var sink = RepositoryEventSink { [weak self] event in private lazy var sink = RepositoryEventSink { [weak self] event in
Task { @MainActor in self?.handle(event: event) } Task { @MainActor in self?.handle(event: event) }
} }
private nonisolated static let maxEvents = 200 private nonisolated static let maxEvents = 200
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
self.coreFactory = coreFactory
}
// MARK: - Lifecycle // MARK: - Lifecycle
func initialize(appDataDir: String) async -> Result<Void, Error> { func initialize(
await runCore { [sink] in appDataDir: String,
self.core?.shutdown() networkConfiguration: RelayConfiguration
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink) ) async -> Result<Void, Error> {
return created guard !isNetworkTransitionInProgress else {
}.map { created in return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
isNetworkTransitionInProgress = true
defer { isNetworkTransitionInProgress = false }
let result = await runCore { [sink] in
if let existing = self.core {
let status = existing.status()
try CoreNetworkLifecycle.requireIdle(
activeTransfers: status.activeTransfers,
activeShares: status.activeShares
)
existing.shutdown()
self.core = nil
}
let created = try self.coreFactory.initialize(
appDataDir: appDataDir,
eventSink: sink,
networkConfiguration: networkConfiguration
)
self.core = created self.core = created
return created
}
switch result {
case .success:
self.refreshSnapshot() self.refreshSnapshot()
self.state.isInitialized = true self.state.isInitialized = true
return .success(())
case .failure(let error):
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
self.state = CoreState()
}
return .failure(error)
} }
} }
@@ -56,8 +152,11 @@ final class CoreRepository: ObservableObject, CoreGateway {
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
guard !sources.isEmpty else { guard !sources.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) return .failure(InvitationError.shareEmpty)
} }
return await runCore { return await runCore {
let result = try self.requireCore().shareFiles( let result = try self.requireCore().shareFiles(
@@ -89,7 +188,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
} }
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> { func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
await runCore { guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try self.requireCore().receive( try self.requireCore().receive(
ticket: ticket, ticket: ticket,
outputDir: outputDir, outputDir: outputDir,
@@ -105,7 +207,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
outputDirectoryUrl: String, outputDirectoryUrl: String,
receiverName: String receiverName: String
) async -> Result<Void, Error> { ) async -> Result<Void, Error> {
await runCore { guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) { try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().receive( try self.requireCore().receive(
ticket: ticket, ticket: ticket,
@@ -248,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
private nonisolated func requireCore() throws -> VnidropCore { private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else { guard let core = self.core else {
throw InvitationError.message("Initialize the core first.") throw InvitationError.coreNotInitialized
} }
return core return core
} }
@@ -409,6 +514,7 @@ private extension ReceiverRequest {
case "refused": return .refused case "refused": return .refused
case "expired": return .expired case "expired": return .expired
case "completed": return .completed case "completed": return .completed
case "failed": return .failed
default: return .unknown default: return .unknown
} }
} }

View File

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

View File

@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
var isTemporaryCopy: Bool = false var isTemporaryCopy: Bool = false
/// When true, `value` is a directory (path or security-scoped folder URL). /// When true, `value` is a directory (path or security-scoped folder URL).
var isDirectory: Bool = false 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 } var id: String { value }
} }
@@ -48,7 +52,7 @@ extension FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> { 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 {} func discardPickedFiles(_ files: [PickedShareFile]) async {}

View File

@@ -19,13 +19,44 @@ struct LocalNotification {
/// Presents notifications even while the app is active. Without a delegate the /// 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, /// system drops the banner when the app is frontmost very visible on macOS,
/// where the app window is usually open when a transfer completes. /// where the app window is usually open when a transfer completes.
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { ///
/// `@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( func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
willPresent notification: UNNotification willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions { ) async -> UNNotificationPresentationOptions {
[.banner, .sound, .list] [.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`. /// Local notification service backed by `UNUserNotificationCenter`.

View File

@@ -82,7 +82,9 @@ func progressForReceiver(
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
) )
} }
if latestKind == .completed && !transferEvents.contains(where: { $0.eventKind == .progress || $0.eventKind == .started }) { // 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( return TransferProgress(
transferId: transferId, phase: .transfer, kind: .completed, transferId: transferId, phase: .transfer, kind: .completed,
labelKey: L10n.Progress.completed, progress: 1, detail: nil labelKey: L10n.Progress.completed, progress: 1, detail: nil

View File

@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
AppLogger.info("lifecycle", "app started", ["platform": environment.name]) AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task { Task {
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir) let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
)
if case .failure(let error) = result { messages.error(error) } if case .failure(let error) = result { messages.error(error) }
} }

View File

@@ -5,13 +5,17 @@ import SFSafeSymbols
/// be swiped away. The endpoint id is the trusted identity; display names are /// be swiped away. The endpoint id is the trusted identity; display names are
/// peer-provided. /// peer-provided.
struct ApprovalModalHost: View { 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 state: ApprovalState
let onAccept: (String) -> Void let onAccept: (String) -> Void
let onRefuse: (String) -> Void let onRefuse: (String) -> Void
var body: some View { var body: some View {
Color.clear Color.clear
.sheet(isPresented: .constant(state.current != nil)) { .sheet(isPresented: $isPresented) {
if let request = state.current { if let request = state.current {
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse) ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)

View File

@@ -7,6 +7,7 @@ enum TransferNotificationKind: Equatable {
case receiveCompleted // An incoming transfer finished downloading. case receiveCompleted // An incoming transfer finished downloading.
case receiveFailed // An incoming transfer failed. case receiveFailed // An incoming transfer failed.
case receiverCompleted // A receiver finished downloading your shared transfer. 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` /// A notification resolved from core state but not yet published. `transferName`
@@ -39,11 +40,17 @@ func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String
/// transfer, excluding already-published ids. /// transfer, excluding already-published ids.
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] { func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
requests.compactMap { request in requests.compactMap { request in
guard request.status == .completed else { return nil } let kind: TransferNotificationKind
let id = "receiver-completed-\(request.id)" 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 } guard !published.contains(id) else { return nil }
return PlannedNotification( return PlannedNotification(
id: id, kind: .receiverCompleted, id: id, kind: kind,
transferName: request.transferName, transferName: request.transferName,
receiver: request.receiverName ?? request.receiverDeviceName receiver: request.receiverName ?? request.receiverDeviceName
) )
@@ -56,6 +63,7 @@ private func transferNotificationId(_ kind: TransferNotificationKind, transferId
case .receiveCompleted: return "receive-completed-\(transferId)" case .receiveCompleted: return "receive-completed-\(transferId)"
case .receiveFailed: return "receive-failed-\(transferId)" case .receiveFailed: return "receive-failed-\(transferId)"
case .receiverCompleted: return "receiver-completed-\(transferId)" case .receiverCompleted: return "receiver-completed-\(transferId)"
case .receiverFailed: return "receiver-failed-\(transferId)"
} }
} }
@@ -176,6 +184,12 @@ final class TransferNotificationCoordinator: ObservableObject {
id: plan.id, id: plan.id,
title: String(localized: L10n.Notifications.receiverCompletedTitle), title: String(localized: L10n.Notifications.receiverCompletedTitle),
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name)) 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) { if case .failure(let error) = await notifications.publish(notification) {
messages.error(error) messages.error(error)

View File

@@ -79,6 +79,15 @@ struct ReceiveScreen: View {
} }
} }
} }
.contextMenu {
if transfer.status.isTerminalReceiveHistory {
Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId)
} label: {
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
}
}
}
} }
} header: { } header: {
Text(String(localized: L10n.Receive.historyTitle)) Text(String(localized: L10n.Receive.historyTitle))

View File

@@ -25,6 +25,11 @@ struct SendState: Equatable {
var selectedTransferId: UInt64? var selectedTransferId: UInt64?
var transferThumbnails: [UInt64: Data] = [:] var transferThumbnails: [UInt64: Data] = [:]
var detailPanel: TransferDetailPanel? 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 receiverHistory: [ReceiverRequestModel] = []
var isLoadingReceivers = false var isLoadingReceivers = false
var isDeleteConfirmationOpen = false var isDeleteConfirmationOpen = false
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
private let messages: UiMessageController private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>() 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( init(
repository: CoreGateway, repository: CoreGateway,
fileSystemService: FileSystemService, fileSystemService: FileSystemService,
@@ -154,7 +171,7 @@ final class SendModel: ObservableObject {
} }
func onFilePickFailed(_ reason: String) { func onFilePickFailed(_ reason: String) {
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason)) messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
} }
func clearSelectedSource() { func clearSelectedSource() {
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
} }
func closeDetailPanel() { state.detailPanel = nil } 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 requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } } func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
@@ -228,12 +256,48 @@ final class SendModel: ObservableObject {
} }
} }
/// Deletes a transfer by id, independent of the detail selection used by the
/// list context menu so it can act inline without navigating into the detail.
func deleteTransfer(id: UInt64) {
if state.isDeleting { return }
state.isDeleting = true
Task {
let result = await repository.delete(transferId: id)
switch result {
case .success:
filePreviewRepository.remove(transferId: id)
if state.selectedTransferId == id {
state.selectedTransferId = nil
state.detailPanel = nil
state.receiverHistory = []
}
state.isDeleting = false
_ = await repository.refresh()
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
case .failure(let error):
state.isDeleting = false
messages.error(error)
}
}
}
/// Cancels/refuses a single receiver by responding to its request negatively. /// Cancels/refuses a single receiver by responding to its request negatively.
/// Uses the core's `respondReceiverRequest` (no backend change); applies to /// Uses the core's `respondReceiverRequest` (no backend change); applies to
/// receivers that are still pending or accepted. /// receivers that are still pending or accepted.
func cancelReceiver(requestId: String) { 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 { 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 { switch result {
case .success: case .success:
if let transferId = state.selectedTransferId { refreshReceivers(transferId) } if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
@@ -300,6 +364,13 @@ final class SendModel: ObservableObject {
state.transferName = "" state.transferName = ""
state.accessPolicy = .requireApproval state.accessPolicy = .requireApproval
state.isSharing = false state.isSharing = false
// Jump straight to the new transfer's share panel (QR + delivery) rather
// than dropping the user on the list to drill in manually. Refresh first
// so the transfer exists in state before it's selected.
_ = await repository.refresh()
state.selectedTransferId = share.transferId
state.detailPanel = .share
refreshReceivers(share.transferId)
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success)) messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
case .failure(let error): case .failure(let error):
state.isSharing = false state.isSharing = false

View File

@@ -7,9 +7,18 @@ struct SendScreen: View {
@ObservedObject var model: SendModel @ObservedObject var model: SendModel
let windowClass: WindowClass let windowClass: WindowClass
/// Transfer pending an inline (list-level) delete confirmation.
@State private var deleteTarget: Transfer?
private var outgoing: [Transfer] { private var outgoing: [Transfer] {
model.coreState.transfers.filter { $0.direction == .send } 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? { private var selectedTransfer: Transfer? {
guard let id = model.state.selectedTransferId else { return nil } guard let id = model.state.selectedTransferId else { return nil }
return outgoing.first { $0.transferId == id } return outgoing.first { $0.transferId == id }
@@ -41,6 +50,19 @@ struct SendScreen: View {
detailView(for: transfer) 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( .adaptiveDrawer(
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }), isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
@@ -49,7 +71,23 @@ struct SendScreen: View {
) { ) {
TransferComposer(model: model, windowClass: windowClass) 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 /// 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 /// alert attached here so they present from the detail's own context (presenting
@@ -59,7 +97,8 @@ struct SendScreen: View {
.adaptiveDrawer( .adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }), isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass, windowClass: windowClass,
onDismiss: model.closeDetailPanel onDismiss: model.closeDetailPanel,
onDismissed: model.shareSheetDidDismiss
) { ) {
if let panel = model.state.detailPanel { if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel) DetailPanelContent(model: model, transfer: transfer, panel: panel)
@@ -91,6 +130,28 @@ struct SendScreen: View {
) )
} }
.buttonStyle(.plain) .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: { } header: {
Text(String(localized: L10n.Send.transfersTitle)) Text(String(localized: L10n.Send.transfersTitle))

View File

@@ -75,31 +75,43 @@ struct TransferComposer: View {
} }
} }
@ViewBuilder
private var actions: some View { private var actions: some View {
let shareTitle = state.isSharing let shareTitle = state.isSharing
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile) ? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
let shareButton = PrimaryButton( return VStack(spacing: 10) {
PrimaryButton(
title: shareTitle, action: model.createShare, title: shareTitle, action: model.createShare,
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized) enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
) )
if windowClass == .phone { // Secondary source actions as an even row of bordered buttons rather than
VStack(spacing: 8) { // bare text links, so they read as controls and align with the primary.
shareButton HStack(spacing: 10) {
QuietButton(title: String(localized: L10n.Button.changeFiles), action: model.selectFile, enabled: !state.isSharing) sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder, enabled: !state.isSharing) sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
} if windowClass != .phone {
} else { sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
HStack(spacing: 8) {
shareButton.fixedSize()
QuietButton(title: String(localized: L10n.Button.changeFiles), action: model.selectFile, enabled: !state.isSharing)
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder, enabled: !state.isSharing)
QuietButton(title: String(localized: L10n.Button.clear), action: model.clearSelectedSource, enabled: !state.isSharing)
} }
} }
} }
} }
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 { private struct SelectedFileCard: View {
let file: PickedShareFile let file: PickedShareFile
let canRemove: Bool let canRemove: Bool

View File

@@ -44,22 +44,19 @@ struct TransferDetailsView: View {
count: pendingReceivers + completedReceivers, count: pendingReceivers + completedReceivers,
onTap: model.openReceivers onTap: model.openReceivers
) )
DetailDestination(
title: String(localized: L10n.Transfer.shareTitle),
description: String(localized: L10n.Transfer.shareDescription),
count: 0,
onTap: model.openShare
)
} }
if isActiveShare {
Section { Section {
if isActiveShare {
Button(role: .destructive) { Button(role: .destructive) {
showStopConfirmation = true showStopConfirmation = true
} label: { } label: {
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle) Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
} }
} }
Button(role: .destructive, action: model.requestDeleteTransfer) {
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
}
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
@@ -69,9 +66,10 @@ struct TransferDetailsView: View {
#endif #endif
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestDeleteTransfer) { Button(action: model.openShare) {
Image(systemSymbol: .trash) Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
} }
.help(String(localized: L10n.Transfer.shareTitle))
} }
} }
.confirmationDialog( .confirmationDialog(
@@ -114,7 +112,7 @@ private struct DetailDestination: View {
} }
Spacer() Spacer()
if count > 0 { if count > 0 {
Text("\(count)") Text(verbatim: "\(count)")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -144,7 +142,8 @@ struct DetailPanelContent: View {
loading: model.state.isLoadingReceivers, loading: model.state.isLoadingReceivers,
events: model.coreState.events, events: model.coreState.events,
transferTotalSize: transfer.totalSize, transferTotalSize: transfer.totalSize,
onCancel: model.cancelReceiver onCancel: model.cancelReceiver,
onAccept: model.acceptReceiver
) )
case .share: case .share:
TransferSharePanel(model: model, transfer: transfer) TransferSharePanel(model: model, transfer: transfer)
@@ -195,6 +194,7 @@ struct ReceiverHistoryPanel: View {
let events: [CoreEventModel] let events: [CoreEventModel]
let transferTotalSize: UInt64 let transferTotalSize: UInt64
let onCancel: (String) -> Void let onCancel: (String) -> Void
let onAccept: (String) -> Void
var body: some View { var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) { PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
@@ -205,7 +205,12 @@ struct ReceiverHistoryPanel: View {
} else { } else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
if index > 0 { Divider().overlay(colors.borderDefault) } 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
)
} }
} }
} }
@@ -226,6 +231,7 @@ private struct ReceiverRow: View {
let receiver: ReceiverRequestModel let receiver: ReceiverRequestModel
let sendProgress: TransferProgress? let sendProgress: TransferProgress?
let onCancel: (String) -> Void let onCancel: (String) -> Void
let onAccept: (String) -> Void
/// Only pending requests can be cancelled per-receiver: the core rejects a /// Only pending requests can be cancelled per-receiver: the core rejects a
/// negative response to an already-accepted request ("...not approved, or it /// negative response to an already-accepted request ("...not approved, or it
@@ -238,6 +244,7 @@ private struct ReceiverRow: View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice) let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
let showLive = sendProgress != nil && receiver.status != .completed let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired && receiver.status != .refused && receiver.status != .expired
&& receiver.status != .failed
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1) Text(name).font(VniType.bodyLarge).lineLimit(1)
@@ -252,11 +259,13 @@ private struct ReceiverRow: View {
.foregroundStyle(receiver.status.statusColor(colors)) .foregroundStyle(receiver.status.statusColor(colors))
} }
if let reason = receiver.reason, !reason.isEmpty { 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) .frame(maxWidth: .infinity, alignment: .leading)
if isCancelable { if isCancelable {
VStack(alignment: .trailing, spacing: 8) {
Button(role: .destructive) { Button(role: .destructive) {
onCancel(receiver.id) onCancel(receiver.id)
} label: { } label: {
@@ -265,6 +274,18 @@ private struct ReceiverRow: View {
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.tint(.red) .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)
}
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -279,24 +300,39 @@ struct TransferSharePanel: View {
var body: some View { var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) { PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
if let ticket = transfer.ticket { switch transfer.invitationPresentation {
qrCard(ticket: ticket) case .ready(let ticket):
let qrImage = QRCode.generate(from: ticket)
qrCard(image: qrImage)
if qrImage != nil {
Text(String(localized: L10n.Transfer.scanQr)) Text(String(localized: L10n.Transfer.scanQr))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
}
ShareActionsView(model: model, transfer: transfer, ticket: ticket) ShareActionsView(model: model, transfer: transfer, ticket: ticket)
} else { case .preparing:
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter) Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable:
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
.foregroundStyle(colors.foregroundLighter)
} }
} }
} }
private func qrCard(ticket: String) -> some View { private func qrCard(image: Image?) -> some View {
ZStack { ZStack {
if let qr = QRCode.generate(from: ticket) { if let image {
qr.interpolation(.none).resizable().scaledToFit().padding(14) image.interpolation(.none).resizable().scaledToFit().padding(14)
} else { } else {
ProgressView() VStack(spacing: 10) {
Image(systemSymbol: .qrcode)
.font(.system(size: 36, weight: .medium))
Text(String(localized: L10n.Transfer.qrUnavailable))
.font(VniType.bodySmall)
.multilineTextAlignment(.center)
}
.foregroundStyle(.black.opacity(0.72))
.padding(22)
} }
} }
.frame(width: 268, height: 268) .frame(width: 268, height: 268)
@@ -305,6 +341,26 @@ struct TransferSharePanel: View {
} }
} }
enum TransferInvitationPresentation: Equatable {
case preparing
case ready(String)
case unavailable
}
extension Transfer {
var invitationPresentation: TransferInvitationPresentation {
switch status {
case .importing:
return .preparing
case .sharing:
guard let ticket, !ticket.isEmpty else { return .preparing }
return .ready(ticket)
case .receiving, .done, .failed, .cancelled, .stopped:
return .unavailable
}
}
}
// MARK: - QR generation (CoreImage) // MARK: - QR generation (CoreImage)
enum QRCode { enum QRCode {
@@ -361,6 +417,7 @@ extension ReceiverDeliveryStatus {
case .refused: return L10n.Transfer.receiverRefused case .refused: return L10n.Transfer.receiverRefused
case .expired: return L10n.Transfer.receiverExpired case .expired: return L10n.Transfer.receiverExpired
case .completed: return L10n.Transfer.receiverCompleted case .completed: return L10n.Transfer.receiverCompleted
case .failed: return L10n.Transfer.receiverFailed
case .unknown: return L10n.Transfer.receiverUnknown case .unknown: return L10n.Transfer.receiverUnknown
} }
} }
@@ -368,7 +425,7 @@ extension ReceiverDeliveryStatus {
func statusColor(_ colors: VniDropColors) -> Color { func statusColor(_ colors: VniDropColors) -> Color {
switch self { switch self {
case .completed: return colors.brandDefault case .completed: return colors.brandDefault
case .refused, .expired: return colors.destructiveDefault case .refused, .expired, .failed: return colors.destructiveDefault
default: return colors.foregroundLighter default: return colors.foregroundLighter
} }
} }

View File

@@ -20,12 +20,7 @@ protocol BugReportService {
/// Offline-safe no-op used until the diagnostics transport is configured. /// Offline-safe no-op used until the diagnostics transport is configured.
struct NoopBugReportService: BugReportService { struct NoopBugReportService: BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> { 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 } func previewLogBytes() async -> Int { 0 }
} }
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
enum DiagnosticsBuildConfig {
static let included = false
}

View File

@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
case preferences case preferences
case appearance case appearance
case notifications case notifications
case network
case storage case storage
case about case about
case bugReport case bugReport
@@ -17,6 +18,7 @@ enum SettingsSection: Hashable {
case .preferences: return L10n.Preferences.title case .preferences: return L10n.Preferences.title
case .appearance: return L10n.Appearance.title case .appearance: return L10n.Appearance.title
case .notifications: return L10n.Notifications.title case .notifications: return L10n.Notifications.title
case .network: return L10n.Settings.networkTitle
case .storage: return L10n.Storage.title case .storage: return L10n.Storage.title
case .about: return L10n.About.title case .about: return L10n.About.title
case .bugReport: return L10n.About.bugReport case .bugReport: return L10n.About.bugReport
@@ -42,7 +44,14 @@ struct SettingsState: Equatable {
var supportsCustomReceiveFolders = true var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system var themeMode: ThemeMode = .system
var notificationPermission: NotificationPermission = .notDetermined var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false var relayMode: RelayPreferenceMode = .automatic
var relayURLs: [String] = []
var relayValidationError: RelayConfigurationValidationError?
var relayConfigurationIsDirty = false
var isApplyingRelayConfiguration = false
var hasActiveNetworkWork = false
var endpointId: String?
var relayApplyErrorKey: String.LocalizationValue?
var deviceInfo: DeviceInfo? var deviceInfo: DeviceInfo?
var appVersion = "" var appVersion = ""
var isLoadingDeviceInfo = false var isLoadingDeviceInfo = false
@@ -66,7 +75,14 @@ struct SettingsState: Equatable {
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders && lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode && lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission && lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion && lhs.appVersion == rhs.appVersion
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
&& lhs.relayValidationError == rhs.relayValidationError
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
&& lhs.endpointId == rhs.endpointId
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo && lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected && lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact && lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
@@ -94,10 +110,10 @@ final class SettingsModel: ObservableObject {
private let notifications: LocalNotificationService private let notifications: LocalNotificationService
private let messages: UiMessageController private let messages: UiMessageController
private let bugReports: BugReportService private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var usernamePersistTask: Task<Void, Never>? private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false private var hasLocalUsernameDraft = false
private var hasRelayConfigurationDraft = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
@@ -108,8 +124,7 @@ final class SettingsModel: ObservableObject {
preferences: AppPreferencesRepository, preferences: AppPreferencesRepository,
notifications: LocalNotificationService, notifications: LocalNotificationService,
messages: UiMessageController, messages: UiMessageController,
bugReports: BugReportService, bugReports: BugReportService
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
) { ) {
self.environment = environment self.environment = environment
self.deviceInfoProvider = deviceInfoProvider self.deviceInfoProvider = deviceInfoProvider
@@ -119,7 +134,6 @@ final class SettingsModel: ObservableObject {
self.notifications = notifications self.notifications = notifications
self.messages = messages self.messages = messages
self.bugReports = bugReports self.bugReports = bugReports
self.diagnosticsIncluded = diagnosticsIncluded
self.state = SettingsState( self.state = SettingsState(
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders, supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
appVersion: environment.appVersion appVersion: environment.appVersion
@@ -133,11 +147,29 @@ final class SettingsModel: ObservableObject {
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode self.state.themeMode = prefs.themeMode
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode
self.state.relayURLs = prefs.relayConfiguration.relayURLs
self.state.relayConfigurationIsDirty = false
}
if folder != previousFolder { Task { await self.validateFolder(folder) } } if folder != previousFolder { Task { await self.validateFolder(folder) } }
} }
.store(in: &cancellables) .store(in: &cancellables)
repository.statePublisher
.sink { [weak self] coreState in
guard let self else { return }
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|| (coreState.status?.activeShares ?? 0) > 0
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
self.state.hasActiveNetworkWork = hasActiveWork
self.state.endpointId = coreState.status?.endpointId
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
self.state.relayApplyErrorKey = nil
}
}
.store(in: &cancellables)
refreshNotificationPermission() refreshNotificationPermission()
loadDeviceInfo() loadDeviceInfo()
} }
@@ -169,7 +201,7 @@ final class SettingsModel: ObservableObject {
} }
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) } 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 resetReceiveFolder() { preferences.resetReceiveFolder() }
/// Whether the current receive folder is the platform default (so the reset /// Whether the current receive folder is the platform default (so the reset
@@ -193,15 +225,117 @@ final class SettingsModel: ObservableObject {
} }
} }
func setDiagnosticsEnabled(_ enabled: Bool) { // MARK: - Network
if !diagnosticsIncluded { return }
Task { func setRelayMode(_ mode: RelayPreferenceMode) {
preferences.setDiagnosticsEnabled(enabled) hasRelayConfigurationDraft = true
messages.show(UiMessage( state.relayMode = mode
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage), if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
tone: .success updateRelayConfigurationDraft()
))
} }
func setRelayURL(_ value: String, at index: Int) {
guard state.relayURLs.indices.contains(index) else { return }
hasRelayConfigurationDraft = true
state.relayURLs[index] = value
updateRelayConfigurationDraft()
}
func addRelayURL() {
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
hasRelayConfigurationDraft = true
state.relayURLs.append("")
updateRelayConfigurationDraft()
}
func removeRelayURL(at index: Int) {
guard state.relayURLs.indices.contains(index) else { return }
hasRelayConfigurationDraft = true
state.relayURLs.remove(at: index)
if state.relayURLs.isEmpty { state.relayURLs = [""] }
updateRelayConfigurationDraft()
}
func applyRelayConfiguration() {
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
let configuration: RelayConfiguration
do {
configuration = try RelayConfigurationValidator.validate(
mode: state.relayMode,
relayURLs: state.relayURLs,
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
)
} catch let error as RelayConfigurationValidationError {
state.relayValidationError = error
state.relayApplyErrorKey = nil
return
} catch {
return
}
let coreState = repository.state
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|| (coreState.status?.activeShares ?? 0) > 0
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
guard !hasActiveWork else {
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
return
}
let previousConfiguration = preferences.preferences.relayConfiguration
state.relayValidationError = nil
state.relayApplyErrorKey = nil
state.isApplyingRelayConfiguration = true
Task {
let applyResult = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: configuration
)
switch applyResult {
case .success:
hasRelayConfigurationDraft = false
preferences.setRelayConfiguration(configuration)
state.isApplyingRelayConfiguration = false
state.relayConfigurationIsDirty = false
messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success))
case .failure(let error):
if let lifecycleError = error as? CoreNetworkLifecycleError {
state.isApplyingRelayConfiguration = false
switch lifecycleError {
case .activeNetworkWork:
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
case .transitionInProgress:
state.relayApplyErrorKey = L10n.Relay.applyFailed
}
return
}
let rollbackResult = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: previousConfiguration
)
state.isApplyingRelayConfiguration = false
if case .success = rollbackResult {
state.relayApplyErrorKey = L10n.Relay.applyFailed
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
} else {
state.relayApplyErrorKey = L10n.Relay.restoreFailed
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
}
}
}
}
private func updateRelayConfigurationDraft() {
state.relayValidationError = nil
state.relayApplyErrorKey = nil
let saved = preferences.preferences.relayConfiguration
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
} }
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value } func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
@@ -325,7 +459,7 @@ final class SettingsModel: ObservableObject {
loadStorageUsage() loadStorageUsage()
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success)) messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
} else { } else {
messages.error(InvitationError.message("Could not delete \(failures) transfer records")) messages.error(InvitationError.deleteRecordsFailed)
} }
} }
} }

View File

@@ -24,6 +24,21 @@ struct SettingsScreen: View {
var body: some View { var body: some View {
NavigationStack(path: path) { NavigationStack(path: path) {
Form { 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 { Section {
NavigationLink(value: SettingsSection.preferences) { NavigationLink(value: SettingsSection.preferences) {
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username) SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
@@ -39,6 +54,17 @@ struct SettingsScreen: View {
NavigationLink(value: SettingsSection.storage) { NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil) SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
} }
}
Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) {
SettingsRow(
icon: .network,
title: String(localized: L10n.Settings.networkTitle),
value: relayModeLabel(model.state.relayMode)
)
}
}
Section {
NavigationLink(value: SettingsSection.about) { NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil) SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
} }
@@ -100,6 +126,8 @@ private struct SettingsSectionContent: View {
AppearanceSettings(model: model) AppearanceSettings(model: model)
case .notifications: case .notifications:
NotificationSettings(model: model) NotificationSettings(model: model)
case .network:
NetworkSettings(model: model)
case .storage: case .storage:
StorageSettings(model: model) StorageSettings(model: model)
case .about: case .about:
@@ -110,6 +138,24 @@ private struct SettingsSectionContent: View {
} }
} }
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
switch mode {
case .automatic: return String(localized: L10n.Relay.modeAutomatic)
case .strictCustom: return String(localized: L10n.Relay.modeCustom)
case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback)
case .localOnly: return String(localized: L10n.Relay.modeLocalOnly)
}
}
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
switch mode {
case .automatic: return L10n.Relay.modeAutomaticDescription
case .strictCustom: return L10n.Relay.modeCustomDescription
case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription
case .localOnly: return L10n.Relay.modeLocalOnlyDescription
}
}
struct SettingsRow: View { struct SettingsRow: View {
let icon: SFSymbol let icon: SFSymbol
let title: String let title: String

View File

@@ -74,6 +74,173 @@ struct NotificationSettings: View {
} }
} }
struct NetworkSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Picker(
"",
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
) {
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
Text(relayModeLabel(mode)).tag(mode)
}
}
.pickerStyle(.inline)
.labelsHidden()
.disabled(model.state.isApplyingRelayConfiguration)
} header: {
Text(String(localized: L10n.Settings.networkTitle))
} footer: {
Text(String(localized: relayModeDescription(model.state.relayMode)))
}
Section {
Label {
Text(String(localized: L10n.Relay.privacyDescription))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemSymbol: .lockShield)
}
.foregroundStyle(.secondary)
}
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
Section {
Text(L10n.Approval.endpointId(deviceId: endpointId))
.font(.footnote.monospaced())
.textSelection(.enabled)
}
}
if model.state.relayMode.usesCustomRelayURLs {
Section {
if model.state.relayMode == .strictCustom {
Label {
Text(String(localized: L10n.Relay.strictWarning))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemSymbol: .exclamationmarkShieldFill)
}
.foregroundStyle(.orange)
}
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
VStack(alignment: .leading, spacing: 6) {
HStack {
TextField(
"",
text: Binding(
get: {
model.state.relayURLs.indices.contains(index)
? model.state.relayURLs[index]
: ""
},
set: { model.setRelayURL($0, at: index) }
),
// `Text(verbatim:)` avoids macOS markdown-linkifying the
// URL-shaped placeholder into a purple link.
prompt: Text(verbatim: "https://relay.example.com")
)
.labelsHidden()
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.disabled(model.state.isApplyingRelayConfiguration)
Button(role: .destructive) {
model.removeRelayURL(at: index)
} label: {
Image(systemSymbol: .minusCircleFill)
}
.buttonStyle(.borderless)
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
.disabled(model.state.isApplyingRelayConfiguration)
}
if let error = model.state.relayValidationError, error.urlIndex == index {
Text(relayValidationMessage(error))
.font(.caption)
.foregroundStyle(.red)
}
}
}
Button(action: model.addRelayURL) {
Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
}
.disabled(
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|| model.state.isApplyingRelayConfiguration
)
} header: {
Text(String(localized: L10n.Relay.customUrlsLabel))
} footer: {
Text(String(localized: L10n.Relay.customUrlsHelp))
}
}
if let error = model.state.relayValidationError, error.urlIndex == nil {
Section {
Label {
Text(relayValidationMessage(error))
} icon: {
Image(systemSymbol: .exclamationmarkTriangleFill)
}
.foregroundStyle(.red)
}
}
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
Section {
Label {
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
} icon: {
Image(systemSymbol: .exclamationmarkTriangleFill)
}
.foregroundStyle(.red)
}
}
Section {
Button(action: model.applyRelayConfiguration) {
HStack {
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
if model.state.isApplyingRelayConfiguration {
Spacer()
ProgressView()
}
}
}
.disabled(
!model.state.relayConfigurationIsDirty
|| model.state.isApplyingRelayConfiguration
|| model.state.hasActiveNetworkWork
)
} footer: {
Text(String(localized: L10n.Relay.applyRestartDescription))
}
}
}
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
switch error {
case .missingURL:
return String(localized: L10n.Relay.validationMissingUrl)
case .tooManyURLs:
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
case .httpsRequired(let index):
return L10n.Relay.validationHttpsRequired(line: index + 1)
case .invalidURL(let index):
return L10n.Relay.validationInvalidUrl(line: index + 1)
case .duplicateURL(let index):
return L10n.Relay.validationDuplicateUrl(line: index + 1)
}
}
struct StorageSettings: View { struct StorageSettings: View {
@ObservedObject var model: SettingsModel @ObservedObject var model: SettingsModel
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
@@ -208,7 +375,7 @@ struct StorageSettings: View {
struct AboutSettings: View { struct AboutSettings: View {
@ObservedObject var model: SettingsModel @ObservedObject var model: SettingsModel
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")! private static let privacyPolicyURL = AppConfig.privacyPolicyURL
var body: some View { var body: some View {
Section { Section {
@@ -248,17 +415,6 @@ struct AboutSettings: View {
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised) Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
} }
} }
if DiagnosticsBuildConfig.included {
Section {
Toggle(isOn: Binding(
get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) }
)) {
Text(String(localized: L10n.Diagnostics.title))
}
}
}
} }
} }

View File

@@ -6,7 +6,7 @@ import UIKit
@MainActor @MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current 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( let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)", name: "\(device.systemName) \(device.systemVersion)",
appVersion: version, appVersion: version,

View File

@@ -5,7 +5,7 @@ import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`. /// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor @MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { 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 host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment( let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString, name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,

View File

@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else { 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 { 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 let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async { 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 { func discardPickedFiles(_ files: [PickedShareFile]) async {
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { 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() } let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources( return await repository.shareSources(

View File

@@ -42,8 +42,24 @@ struct MacFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { 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 { let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
} }

View File

@@ -107,9 +107,15 @@ enum PickerSupport {
) )
#else #else
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) } 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( return PickedShareFile(
value: url.path, displayName: url.lastPathComponent, sizeBytes: size, value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: false, isDirectory: isDirectory isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
) )
#endif #endif
} }

View File

@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
picker.delegate = self picker.delegate = self
picker.modalPresentationStyle = .formSheet picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else { 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) presenter.present(picker, animated: true)
} }
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) { func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel() cancel()
guard let presenter = topPresenter() else { 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 ensureCameraAccess { [weak self] granted in
guard let self else { return } guard let self else { return }
guard granted else { 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 let scanner = QrScannerViewController { result in
self.qrController = nil self.qrController = nil
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) { func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel() cancel()
guard NFCNDEFReaderSession.readingAvailable else { 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 let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil self?.nfcReader = nil
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
let result = documentResult let result = documentResult
documentResult = nil documentResult = nil
result?(Result { 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() let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } } defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
} }
func cancelScan() { func cancelScan() {
finish(.failure(InvitationError.message("QR scanning was cancelled"))) finish(.failure(InvitationError.cancelled))
} }
private func configureSession() { private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video), guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device), let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { session.canAddInput(input) else {
return finish(.failure(InvitationError.message("No camera is available"))) return finish(.failure(InvitationError.cameraUnavailable))
} }
session.addInput(input) session.addInput(input)
let output = AVCaptureMetadataOutput() let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.message("Could not configure the QR scanner"))) return finish(.failure(InvitationError.cameraUnavailable))
} }
session.addOutput(output) session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main) output.setMetadataObjectsDelegate(self, queue: .main)
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() { func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true) 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 session = reader
reader.begin() reader.begin()
} }
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return } if finished { return }
let cancelled = (error as NSError).code == 200 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]) { func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
.flatMap { $0.records } .flatMap { $0.records }
.compactMap { payloadAsInvitation($0) } .compactMap { payloadAsInvitation($0) }
.first .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 return ticket
} }
session.invalidate() session.invalidate()

View File

@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
} }
panel.begin { response in panel.begin { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled"))) onResult(.failure(InvitationError.cancelled))
return return
} }
onResult(Result { onResult(Result {
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
} }
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) { 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) { func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) onResult(.failure(InvitationError.nfcUnavailable))
} }
func cancel() {} 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) { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite() cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else { guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.message("NFC is unavailable on this device"))) onResult(.failure(InvitationError.nfcUnavailable))
return return
} }
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
@MainActor @MainActor
private func present(_ controller: UIViewController) throws { private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else { guard let presenter = topPresenter() else {
throw InvitationError.message("Could not find an iOS view controller") throw InvitationError.viewControllerUnavailable
} }
presenter.present(controller, animated: true) presenter.present(controller, animated: true)
} }
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() { func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false) 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 session = reader
reader.begin() reader.begin()
} }
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return } if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled 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, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let firstTag = tags.first else { 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 // CoreNFC completion handlers run on the session's `.main` queue; these
// framework values are safe to use there. // 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)) } if let queryError { return self.finish(.failure(queryError)) }
switch status { switch status {
case .notSupported: case .notSupported:
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF"))) self.finish(.failure(InvitationError.nfcFailed))
case .readOnly: case .readOnly:
self.finish(.failure(InvitationError.message("This NFC tag is read-only"))) self.finish(.failure(InvitationError.nfcFailed))
default: default:
guard let message = self.invitationMessage() else { 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 tag.writeNDEF(message) { writeError in
if let writeError { if let writeError {
self.finish(.failure(writeError)) self.finish(.failure(writeError))
} else { } else {
session.alertMessage = "Invitation written" session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
session.invalidate() session.invalidate()
self.finish(.success(())) self.finish(.success(()))
} }

View File

@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
panel.allowedContentTypes = [] panel.allowedContentTypes = []
panel.begin { response in panel.begin { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled"))) onResult(.failure(InvitationError.cancelled))
return return
} }
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) }) onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
do { do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName) let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else { guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.message("No window available"))) onResult(.failure(InvitationError.noWindowAvailable))
return return
} }
let picker = NSSharingServicePicker(items: [url]) let picker = NSSharingServicePicker(items: [url])
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
} }
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) { 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() {} 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,57 @@
{
"fill" : {
"linear-gradient" : [
"extended-gray:1.00000,1.00000",
"srgb:0.84942,0.81480,0.95401,1.00000"
]
},
"groups" : [
{
"blend-mode" : "normal",
"blur-material" : null,
"layers" : [
{
"image-name" : "Mask.svg",
"name" : "Mask"
}
],
"lighting" : "individual",
"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" : "layer-color",
"opacity" : 0.8
},
"translucency" : {
"enabled" : true,
"value" : 0.4
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

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,8 @@
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>VniDrop</string> <string>VniDrop</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- macOS: a single instance only; re-launching activates the running app <!-- macOS: a single instance only; re-launching activates the running app
instead of spawning another copy. --> instead of spawning another copy. -->
<key>LSMultipleInstancesProhibited</key> <key>LSMultipleInstancesProhibited</key>
@@ -55,6 +57,20 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string> <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> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NFCReaderUsageDescription</key> <key>NFCReaderUsageDescription</key>
@@ -73,8 +89,6 @@
<false/> <false/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string>
<string>processing</string>
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>

View File

@@ -2,10 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <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> <key>com.apple.developer.nfc.readersession.formats</key>
<array> <array>
<string>NDEF</string> <string>TAG</string>
</array> </array>
<!-- macOS App Sandbox: user-selected files for share/receive, and network <!-- 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 @Binding var isPresented: Bool
let windowClass: WindowClass let windowClass: WindowClass
let onDismiss: () -> Void 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 @ViewBuilder let drawerContent: () -> DrawerContent
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.sheet( content.sheet(
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }) isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
onDismiss: onDismissed
) { ) {
SheetChrome(onClose: onDismiss) { drawerContent() } SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone)) .modifier(PhoneDetents(enabled: windowClass == .phone))
@@ -56,11 +61,12 @@ extension View {
isPresented: Binding<Bool>, isPresented: Binding<Bool>,
windowClass: WindowClass, windowClass: WindowClass,
onDismiss: @escaping () -> Void, onDismiss: @escaping () -> Void,
onDismissed: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> DrawerContent @ViewBuilder content: @escaping () -> DrawerContent
) -> some View { ) -> some View {
modifier(AdaptiveDrawer( modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass, isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, drawerContent: content onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
)) ))
} }
} }

View File

@@ -42,7 +42,7 @@ struct ProgressRow: View {
label.font(.subheadline).lineLimit(1) label.font(.subheadline).lineLimit(1)
Spacer() Spacer()
if let progress { if let progress {
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary) Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
} }
} }
if let detail { if let detail {

View File

@@ -5,6 +5,9 @@ import VnidropCore
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs. /// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error { extension Error {
func toUiText() -> UiText { func toUiText() -> UiText {
if let invitation = self as? InvitationError {
return invitation.uiText
}
if let vni = self as? VnidropError { if let vni = self as? VnidropError {
switch vni { switch vni {
case .Ticket: case .Ticket:
@@ -40,6 +43,7 @@ extension Error {
/// True when the user intentionally backed out of a flow. /// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool { 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 } if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased() let haystack = technicalDetail.lowercased()
if haystack.isEmpty { if haystack.isEmpty {
@@ -76,6 +80,72 @@ 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 { private func transferUiText(_ reason: String) -> UiText {
let detail = reason.lowercased() let detail = reason.lowercased()
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") { if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {

View File

@@ -1,9 +1,11 @@
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS). # XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
# Regenerate the project with: xcodegen generate (run from apple/) # Regenerate the project with: xcodegen generate (run from apple/)
# Requires two generated inputs first (both gitignored), before xcodegen: # Requires three generated inputs first (all gitignored), before xcodegen:
# - Rust core: apple/scripts/build-core.sh debug # - Rust core: apple/scripts/build-core.sh debug
# - Localization: (cd localization && bun run src/cli.ts generate) # - Localization: (cd localization && bun run src/cli.ts generate)
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift # -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
# - Versions: packaging/version/generate-apple-xcconfig.sh all
# -> Generated/StoreVersion.xcconfig, Generated/DirectVersion.xcconfig
name: VniDrop name: VniDrop
options: options:
bundleIdPrefix: com.vnidrop bundleIdPrefix: com.vnidrop
@@ -12,9 +14,22 @@ options:
macOS: "15.0" macOS: "15.0"
createIntermediateGroups: true 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). # Project-wide build settings (applied to every target/config).
settings: settings:
base: 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. # Strip unreachable code from release binaries.
DEAD_CODE_STRIPPING: YES DEAD_CODE_STRIPPING: YES
# Flag user-facing strings that aren't localized (the app ships 9 languages). # Flag user-facing strings that aren't localized (the app ships 9 languages).
@@ -26,27 +41,36 @@ packages:
SFSafeSymbols: SFSafeSymbols:
url: https://github.com/SFSafeSymbols/SFSafeSymbols url: https://github.com/SFSafeSymbols/SFSafeSymbols
from: "5.3.0" 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: # Shared definition for the two shipping app targets. `VniDrop` (App Store /
VniDrop: # 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 type: application
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources: sources:
- path: VniDrop - path: VniDrop
excludes: excludes:
- "Resources/Info.plist" - "Resources/Info.plist"
- "Resources/VniDrop.entitlements" - "Resources/VniDrop.entitlements"
- "Resources/VniDropDirect.entitlements"
- "Resources/**/.DS_Store" - "Resources/**/.DS_Store"
settings: settings:
base: base:
PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0" MARKETING_VERSION: "$(PRODUCT_VERSION)"
CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: NO GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist 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 # Mirror the Info.plist identity so Xcode's Identity editor shows it too
# (the editor reads these build settings, not the manual plist). # (the editor reads these build settings, not the manual plist).
INFOPLIST_KEY_CFBundleDisplayName: VniDrop INFOPLIST_KEY_CFBundleDisplayName: VniDrop
@@ -59,16 +83,64 @@ targets:
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync. # AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
configs: configs:
debug:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release: 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: dependencies:
- package: VnidropCore - package: VnidropCore
- package: SFSafeSymbols - package: SFSafeSymbols
- sdk: SystemConfiguration.framework - sdk: SystemConfiguration.framework
- sdk: Security.framework - sdk: Security.framework
- sdk: libresolv.tbd - 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: VniDropTests:
type: bundle.unit-test type: bundle.unit-test
@@ -76,6 +148,7 @@ targets:
configFiles: configFiles:
Debug: Signing.xcconfig Debug: Signing.xcconfig
Release: Signing.xcconfig Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources: sources:
- path: Tests - path: Tests
settings: settings:
@@ -97,3 +170,21 @@ schemes:
config: Debug config: Debug
targets: targets:
- VniDropTests - 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>

View File

@@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
# This never touches the Rust crate — it only changes how the build is invoked. # This never touches the Rust crate — it only changes how the build is invoked.
export CARGO_PROFILE_DEV_STRIP=none export CARGO_PROFILE_DEV_STRIP=none
# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build
# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo
# forbids overriding `lto` per build-override, so disable thin LTO for the whole
# release build here — the crate is still fully optimized (opt-level 3, debuginfo
# stripped), which is what shrinks the static lib. This never edits the Cargo crate.
export CARGO_PROFILE_RELEASE_LTO=false
IOS_TARGET="aarch64-apple-ios" IOS_TARGET="aarch64-apple-ios"
SIM_ARM_TARGET="aarch64-apple-ios-sim" SIM_ARM_TARGET="aarch64-apple-ios-sim"
SIM_X64_TARGET="x86_64-apple-ios" SIM_X64_TARGET="x86_64-apple-ios"

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/"

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties
# so app-wide constants (privacy policy URL, …) have a single source of truth
# across Apple and KMP. Regenerate instead of editing the output.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}"
output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}"
read_property() {
local key=$1
local value
value="$(sed -n "s/^${key}=//p" "$config_file")"
[[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; }
[[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] ||
{ printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; }
printf '%s' "$value"
}
# Escape for a Swift string literal.
swift_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
privacy_url="$(read_property PRIVACY_POLICY_URL)"
mkdir -p "$output_dir"
tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")"
cat > "$tmp" <<EOF
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
// Regenerate this file instead of editing it.
import Foundation
/// App-wide constants injected at build time from the shared \`app.properties\`.
enum AppConfig {
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
}
EOF
mv "$tmp" "$output_dir/AppConfig.swift"

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,59 @@
#!/usr/bin/env bash
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
generator="$script_dir/../generate-appconfig.sh"
repo_root="$(cd "$script_dir/../../.." && pwd)"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
# Run the generator against a fixture app.properties, emitting into a temp dir.
generate() {
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
"$generator"
}
expect_failure() {
if "$@" >/dev/null 2>&1; then
printf 'Expected command to fail: %s\n' "$*" >&2
exit 1
fi
}
assert_contains() {
local file=$1 needle=$2
grep -qF "$needle" "$file" ||
{ printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; }
}
out="$scratch/out/AppConfig.swift"
# 1. Nominal value is emitted verbatim as a Swift URL literal.
printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties"
generate
assert_contains "$out" 'URL(string: "https://example.test/privacy/")!'
assert_contains "$out" 'enum AppConfig'
# 2. Characters special to a Swift string literal are escaped.
printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties"
generate
assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!'
# 3. A missing key fails instead of emitting an empty value.
printf 'OTHER_KEY=value\n' > "$scratch/app.properties"
expect_failure generate
# 4. A duplicated key fails.
printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties"
expect_failure generate
# 5. The real committed app.properties produces an https URL.
VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator"
assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://'
printf 'generate-appconfig tests passed.\n'

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 { plugins {
// this is necessary to avoid the plugins to be loaded multiple times // this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader // in each subproject's classloader
@@ -13,3 +15,67 @@ plugins {
alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) 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)
}

40
ci_scripts/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Xcode Cloud CI scripts
Xcode Cloud runs the scripts in this directory around each build. Only
`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` /
`ci_post_xcodebuild.sh` here if later steps are needed.
## What `ci_post_clone.sh` does
The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not**
committed — they are produced by XcodeGen, localization, and the Rust core build.
Since Xcode Cloud only checks out the repository, the post-clone script:
1. installs `swiftlint`, `xcodegen`, and `bun`;
2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from
the matching GitHub Release asset `VnidropCore-<version>.zip` — Xcode Cloud
never builds Rust;
3. runs localization + version/app config codegen and `xcodegen generate`
(equivalent to `make apple-project` without the `apple-core` step).
The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release
before an Xcode Cloud build for that version runs (see
`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`).
### Overrides (env vars, optional)
| Variable | Default | Purpose |
|----------|---------|---------|
| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from |
| `VNIDROP_CORE_TAG` | `v<product-version>` | Release tag holding the core asset |
## Workflow configuration (App Store Connect)
The workflow itself (product, scheme, triggers, actions) is configured in App
Store Connect, not in the repository. Point it at:
- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script)
- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`)
Archive actions use the release Rust profile via the published core asset; build
and test actions reuse the same prebuilt core.

72
ci_scripts/ci_post_clone.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
#
# Xcode Cloud post-clone step.
#
# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a
# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud
# only checks out the repository, so this script:
# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun);
# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the
# matching GitHub Release asset — we never build Rust here;
# 3. reproduces `make apple-project` minus the Rust `apple-core` step.
#
# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH
# points at the checked-out repository root.
set -euo pipefail
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
cd "$REPO_ROOT"
echo "==> Installing build tooling (Homebrew)"
# swiftlint: enforced by a build phase (fails the build if missing).
# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml.
brew install swiftlint xcodegen
echo "==> Installing Bun (localization generator)"
if ! command -v bun >/dev/null 2>&1; then
curl -fsSL https://bun.sh/install | bash
fi
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
export PATH="$BUN_INSTALL/bin:$PATH"
# --- Prebuilt core: download instead of building Rust -------------------------
# The Apple core (xcframework + UniFFI bindings) is published as a release asset
# by apple/scripts/package-core.sh. See docs at the top of that script.
VERSION="$(packaging/version/resolve-version.sh product)"
CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}"
CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}"
CORE_ZIP="VnidropCore-$VERSION.zip"
CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG"
PKG_DIR="$REPO_ROOT/apple/VnidropCore"
DOWNLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG"
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP"
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256"
echo "==> Verifying checksum"
(
cd "$DOWNLOAD_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum --check "$CORE_ZIP.sha256"
else
shasum -a 256 --check "$CORE_ZIP.sha256"
fi
)
echo "==> Installing core into apple/VnidropCore"
unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted"
# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh).
rm -rf "$PKG_DIR/vnidrop.xcframework"
cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework"
mkdir -p "$PKG_DIR/Sources/VnidropCore"
cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
# --- Generate the project (everything except the Rust core) -------------------
echo "==> Generating localization, version/app config, and the Xcode project"
make localization apple-version-config apple-app-config
(cd "$REPO_ROOT/apple" && xcodegen generate)
echo "==> ci_post_clone complete"

View File

@@ -1,5 +1,5 @@
# Default command configuration. Override locally in the ignored # Default command configuration. Override local tool paths in the ignored
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0). # config.override.mk or on the command line.
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
HOST_OS := windows HOST_OS := windows
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
OPEN ?= open OPEN ?= open
POWERSHELL ?= pwsh 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_PROFILE ?= debug
APPLE_CONFIGURATION ?= Debug APPLE_CONFIGURATION ?= Debug
APPLE_DESTINATION ?= APPLE_DESTINATION ?=

View File

@@ -91,6 +91,11 @@ or temporary tag. Receive downloads keep a temporary tag through export and beco
reclaimable after publication. Restart reconciliation repairs active-share tags, reclaimable after publication. Restart reconciliation repairs active-share tags,
removes orphan share tags, and never restores a stopped share. removes orphan share tags, and never restores a stopped share.
The explicit transfer-cache action is available only when no transfer or share is
active. It shuts the core down cleanly, removes the app-owned blob store, and then
restarts the core with the same identity and network configuration. Deleting all
transfer records invokes the same cleanup after live shares have been stopped.
## Resource Limits ## Resource Limits
`CoreLimits` controls source count, collection files and bytes, path and ticket `CoreLimits` controls source count, collection files and bytes, path and ticket

View File

@@ -16,7 +16,7 @@ blake3 = "1.8.3"
data-encoding = "2.11.0" data-encoding = "2.11.0"
futures = "0.3" futures = "0.3"
futures-lite = "2.6.1" futures-lite = "2.6.1"
iroh = "1.0.0" iroh = "1.0.3"
iroh-blobs = "0.103.0" iroh-blobs = "0.103.0"
irpc = "0.17.0" irpc = "0.17.0"
irpc-iroh = "0.17.0" irpc-iroh = "0.17.0"
@@ -36,4 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] }
walkdir = "2.5.0" walkdir = "2.5.0"
[dev-dependencies] [dev-dependencies]
iroh-relay = { version = "1.0.3", features = ["server"] }
tempfile = "3.27.0" tempfile = "3.27.0"

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

@@ -1,9 +1,175 @@
use anyhow::Context; use anyhow::Context;
use iroh::RelayUrl;
use iroh_blobs::Hash; use iroh_blobs::Hash;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, str::FromStr};
use crate::error::VnidropError;
use crate::util::{non_empty, now_ms}; use crate::util::{non_empty, now_ms};
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum CoreRelayMode {
Automatic,
StrictCustom,
CustomWithDirectFallback,
LocalOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct CoreNetworkConfig {
pub mode: CoreRelayMode,
pub relay_urls: Vec<String>,
}
impl Default for CoreNetworkConfig {
fn default() -> Self {
Self {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
}
}
}
impl CoreNetworkConfig {
pub(crate) fn validated_relay_urls(&self) -> anyhow::Result<Vec<RelayUrl>> {
match self.mode {
CoreRelayMode::Automatic | CoreRelayMode::LocalOnly => {
if !self.relay_urls.is_empty() {
anyhow::bail!(
"{} relay mode must not include custom relay URLs",
match self.mode {
CoreRelayMode::Automatic => "automatic",
CoreRelayMode::LocalOnly => "local-only",
CoreRelayMode::StrictCustom
| CoreRelayMode::CustomWithDirectFallback => unreachable!(),
}
);
}
Ok(Vec::new())
}
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
if self.relay_urls.is_empty() {
anyhow::bail!("custom relay mode requires at least one relay URL");
}
if self.relay_urls.len() > MAX_CUSTOM_RELAYS {
anyhow::bail!(
"custom relay mode supports at most {MAX_CUSTOM_RELAYS} relay URLs"
);
}
let mut seen = BTreeSet::new();
let mut validated = Vec::with_capacity(self.relay_urls.len());
for (index, value) in self.relay_urls.iter().enumerate() {
if value.is_empty()
|| value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
anyhow::bail!(
"relay URL must be non-empty and contain no whitespace or control characters"
);
}
if value.len() > MAX_RELAY_URL_BYTES {
anyhow::bail!(
"relay URL is {} bytes, limit is {MAX_RELAY_URL_BYTES}",
value.len()
);
}
let url = RelayUrl::from_str(value)
.with_context(|| format!("invalid relay URL at position {}", index + 1))?;
let secure = url.scheme() == "https";
let loopback_http = url.scheme() == "http"
&& url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
});
if !secure && !loopback_http {
anyhow::bail!(
"relay URL must use HTTPS; HTTP is allowed only for loopback development relays"
);
}
if url.host_str().is_none() {
anyhow::bail!("relay URL must include a host");
}
if url.port() == Some(0) {
anyhow::bail!("relay URL port must be between 1 and 65535");
}
if value.contains('@') || !url.username().is_empty() || url.password().is_some()
{
anyhow::bail!("relay URL must not contain credentials");
}
if url.query().is_some() || url.fragment().is_some() {
anyhow::bail!("relay URL must not contain a query or fragment");
}
if url.path() != "/" {
anyhow::bail!("relay URL must not contain a path");
}
if !seen.insert(url.clone()) {
anyhow::bail!("custom relay URLs must not contain duplicates");
}
validated.push(url);
}
Ok(validated)
}
}
}
}
#[uniffi::export]
pub fn default_core_network_config() -> CoreNetworkConfig {
CoreNetworkConfig::default()
}
/// Removes the blob store after its owning core has shut down.
///
/// Callers must verify that no transfer or share is active before shutdown.
#[uniffi::export]
pub fn clear_inactive_transfer_cache(app_data_dir: String) -> Result<u64, VnidropError> {
let result = (|| -> anyhow::Result<u64> {
let app_data_dir = PathBuf::from(app_data_dir);
anyhow::ensure!(
app_data_dir.is_absolute(),
"app data directory must be absolute"
);
anyhow::ensure!(
app_data_dir.file_name().is_some(),
"app data directory must not be a filesystem root"
);
let blobs = app_data_dir.join("blobs");
let metadata = match std::fs::symlink_metadata(&blobs) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(error.into()),
};
anyhow::ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"blob store path is not a directory"
);
let bytes = walkdir::WalkDir::new(&blobs)
.follow_links(false)
.into_iter()
.try_fold(0u64, |total, entry| {
let entry = entry?;
let metadata = entry.metadata()?;
Ok::<_, walkdir::Error>(if metadata.is_file() {
total.saturating_add(metadata.len())
} else {
total
})
})?;
std::fs::remove_dir_all(blobs)?;
Ok(bytes)
})();
result.map_err(VnidropError::filesystem)
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreLimits { pub struct CoreLimits {
pub max_sources: u64, pub max_sources: u64,

View File

@@ -8,7 +8,10 @@ use uuid::Uuid;
use crate::{ use crate::{
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS}, access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
event_hub::EventHub, event_hub::EventHub,
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer}, handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
RequestTransfer,
},
repository::{ReceiverRequestInsert, Repository}, repository::{ReceiverRequestInsert, Repository},
transfer_state::ReceiverRequestStatus, transfer_state::ReceiverRequestStatus,
util::now_ms, 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( pub(crate) fn new(
repository: Repository, repository: Repository,
event_hub: Arc<EventHub>, event_hub: Arc<EventHub>,

View File

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

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