c6QioeD?G>98CH&nfU`I(kNXoC|S0I6VFnAJ=FPnA-fFMVugU+KjMjxmBrIGMm
zdcE>@R`~1~MycGJFhZ34*SVDPNp$$J650#WW%Cg)s$}ROv`-P*<*y(@%_FY`g6eTn
z?0ud;E+(J=JqVfn#W|A(?P48osQ_W1n#V@R7=}
z9l86*v&>~$j4)0w6)WCOP(IDF&iIFm0l4^}RBZ8tAG9CDJKneV!Jx8Ki~{1lUQ+JN
zdPeibf=jkWWh{^!3lJc$r6euO8t=|T#M+YRko%M$CE9EL1t?+fjCK8!(}PTpI);TA
z1W8P(^v3RXspbIr7F3@OjSh$PIbc#smg;{mDg8o8H!MbhQ}wkXsuE(eaSN>*WMls342^S9tb!<7jn_sZ1h!s!=THToB5$(47#fm1maa?Bq*52K#;pa!0uSOQBW;J^i6BbuKtJdp$9sdrk!FSi&VdeoQ;q=&es*=}fyBXQZd
zv81XSUsr}j0J=Mo5G@m}ipoqx(`WL@?F<()^O34Z&0EPzK)3<56Xez7@JP|4{$Xg^
zhGrl^ntc3)IteTL$IPAuW|m;w*3dZ<{~#gUFsRP-A2mpB7plCLd;z3XTyg$NMMerJ
zPmWn2ceB3XrKfuduFd;05n-kK=hExX7fQGH?OtSrZtg`FIz3)K+1FK^qQ96%=Ci|I
zKT}6mlLVB1Se?etR`vJM3VfmTV3&8NG#zvg%`1P7l*rC0Tm3RiW=x^PrtI?o^Hhfp
zP%z%cZEK-Pr@}4RvHQ_a=S4sLCYm>#%jyeY1i3h=94B>ot`47`|bYW@LyKrU4w8&KLl0wXXS`OFPRRP=+u
zh_)*LzK~Nc#v+hFH7c|axAc>F*Hloa?^9CKrZ5-ckKQoMS3DW^(H0b`bvh(!K9ydS
z*%(+OJxfxi3wDQ~M$`_2GMZoMdU)*j(j6Py=HSk<%9mFKfe8;AHATRvHn^hRIgQmO
zm3+0IY^9KwIhx^zdW>2}=D|a$Wjl_xKo?(_nhx@yfl?niqYbzv`D)iJQdJubVxmfA
z2QM?Uv49>d6pA2!Hn%x9JorUHI&NFNeXTL}BHTQHjrT7B)x*tSGm~pM_pfTln|}&j
zh+>>!gamhkD}V$V%P(6mR--99hURW&nAWTc@!g%G%@YV?g%kUvZ~j(
z+H`lK?@}T^^x-DDqSr;LZ(1pjwFpK-svsUz+0*p+uz)P{Sju79v*pSfHOdav$!lOy
zjj(S;lTo{-z><9ElEbHR_wx>FVRlUBTk`&bBfNLfuvOa{)hbB!?7-6INlGc^sJ1O=3MSz
z^T-&7#`MCZ*xlwE6`J*e?*Pc7T$o_5maZ&%HBQ}u3(4sEMJb*;6Rt$#lx3Ai#(fwo
zW!f?d^LoXNcdj%TvSxL2algph!7AUbYP`mW=|+?V!#dyB)de
zlEu5MPo8pA&a?cXCU#m?98A3lDPL8iUg}gf3&{r;`zK2c$vJXyXM&d~Wt;$RD?)>qUBS_iE2TZc^Z);W9G-_3#T
z1paxKf-9f)95|N0CqS1$G41Erx;=mBcB(zDbUY5hD3n1__%C!RhF|URW}V*$
zaGfclqL!UL-}Pjl?K`s`z*UqHQk#87pR>KTHhF92Q&7|W1fy?PLN0Kd`$Qaj0t_7I
zotyt?zO}dYu{wNf0R$iH=-Z-S0{;v*PW}B+5QUdNU;pzH2uc3uH2$B@U(io9XgKFaH<&tEp-L
From f3124371ee60852e3ae322f89477fd25ef7ac811 Mon Sep 17 00:00:00 2001
From: cdricms <36056008+cdricms@users.noreply.github.com>
Date: Sat, 25 Jul 2026 19:35:59 +0200
Subject: [PATCH 05/13] fix(apple): resolve App Store validation errors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
apple/VniDrop/Resources/Info.plist | 4 +---
apple/VniDrop/Resources/VniDrop.entitlements | 2 +-
apple/project.yml | 4 +++-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist
index b204cdf..31ed620 100644
--- a/apple/VniDrop/Resources/Info.plist
+++ b/apple/VniDrop/Resources/Info.plist
@@ -21,7 +21,7 @@
CFBundleDisplayName
VniDrop
ITSAppUsesNonExemptEncryption
-
+
LSMultipleInstancesProhibited
@@ -75,8 +75,6 @@
UIBackgroundModes
- fetch
- processing
remote-notification
UIFileSharingEnabled
diff --git a/apple/VniDrop/Resources/VniDrop.entitlements b/apple/VniDrop/Resources/VniDrop.entitlements
index 09743a1..d2b9cb0 100644
--- a/apple/VniDrop/Resources/VniDrop.entitlements
+++ b/apple/VniDrop/Resources/VniDrop.entitlements
@@ -2,7 +2,7 @@
-
+
com.apple.developer.nfc.readersession.formats
NDEF
diff --git a/apple/project.yml b/apple/project.yml
index 806fc85..2e05ee7 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -44,7 +44,7 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
- CURRENT_PROJECT_VERSION: "1"
+ CURRENT_PROJECT_VERSION: "3"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
@@ -63,6 +63,8 @@ targets:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
+ # Produce a dSYM in the archive so symbol upload succeeds.
+ DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
dependencies:
- package: VnidropCore
- package: SFSafeSymbols
From 22b93ce94e6433f867aef0520a36542a7a2a1bf1 Mon Sep 17 00:00:00 2001
From: cdricms <36056008+cdricms@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:02:31 +0200
Subject: [PATCH 06/13] feat(apple): keep iOS transfers alive in the background
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
apple/VniDrop/App/AppGraph.swift | 2 +
apple/VniDrop/App/RootView.swift | 8 +-
.../Core/BackgroundActivityController.swift | 77 +++++++++++++++++++
.../Features/Settings/SettingsScreen.swift | 15 ++++
localization/strings.json | 34 ++++++++
5 files changed, 135 insertions(+), 1 deletion(-)
create mode 100644 apple/VniDrop/Core/BackgroundActivityController.swift
diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift
index d261499..9cd7f97 100644
--- a/apple/VniDrop/App/AppGraph.swift
+++ b/apple/VniDrop/App/AppGraph.swift
@@ -13,6 +13,7 @@ final class AppGraph: ObservableObject {
let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator
+ let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies
@@ -39,6 +40,7 @@ final class AppGraph: ObservableObject {
visibility: visibility,
messages: messages
)
+ self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
}
diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift
index 4954e07..25f715c 100644
--- a/apple/VniDrop/App/RootView.swift
+++ b/apple/VniDrop/App/RootView.swift
@@ -81,12 +81,18 @@ struct RootView: View {
switch phase {
case .active:
graph.visibility.setForeground(true)
+ graph.backgroundActivity.didBecomeForeground()
settingsModel.refreshNotificationPermission()
// Reconcile against the durable snapshot: while the window was
// unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() }
- case .background, .inactive:
+ case .background:
+ graph.visibility.setForeground(false)
+ // Hold the process open for iOS's grace window so an active
+ // transfer can finish and notify before suspension.
+ graph.backgroundActivity.didEnterBackground()
+ case .inactive:
graph.visibility.setForeground(false)
@unknown default:
break
diff --git a/apple/VniDrop/Core/BackgroundActivityController.swift b/apple/VniDrop/Core/BackgroundActivityController.swift
new file mode 100644
index 0000000..17479a9
--- /dev/null
+++ b/apple/VniDrop/Core/BackgroundActivityController.swift
@@ -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
+}
diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift
index 5b436fa..4d2945b 100644
--- a/apple/VniDrop/Features/Settings/SettingsScreen.swift
+++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift
@@ -24,6 +24,21 @@ struct SettingsScreen: View {
var body: some View {
NavigationStack(path: path) {
Form {
+ #if os(iOS)
+ // iOS suspends the app in the background, so serving/receiving
+ // can't run indefinitely there (unlike macOS). Tell users up
+ // front so the platform limit doesn't read as a bug.
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Label(String(localized: L10n.Settings.iosBackgroundNoticeTitle), systemSymbol: .moonZzz)
+ .font(.subheadline.weight(.semibold))
+ Text(String(localized: L10n.Settings.iosBackgroundNoticeBody))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+ #endif
Section {
NavigationLink(value: SettingsSection.preferences) {
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
diff --git a/localization/strings.json b/localization/strings.json
index b02514d..e44bfd8 100644
--- a/localization/strings.json
+++ b/localization/strings.json
@@ -3430,6 +3430,40 @@
"ru": "Дополнительно"
}
},
+ "settings_ios_background_notice_body": {
+ "context": "Settings overview: explains iOS/iPadOS background limits so users don't think the app is broken. Apple platforms only.",
+ "targets": [
+ "apple"
+ ],
+ "translations": {
+ "en": "iPhone and iPad limit what apps may do in the background. VniDrop keeps a transfer that's already running alive long enough to finish and notify you after you leave the app, but it can't keep serving or receiving on its own once it's been in the background for a while. For long transfers, keep VniDrop open. On Mac, transfers continue in the background normally.",
+ "fr": "L’iPhone et l’iPad limitent ce que les apps peuvent faire en arrière-plan. VniDrop maintient un transfert déjà en cours assez longtemps pour le terminer et vous avertir après avoir quitté l’app, mais il ne peut pas continuer à envoyer ou recevoir seul une fois resté en arrière-plan un certain temps. Pour les transferts longs, gardez VniDrop ouvert. Sur Mac, les transferts se poursuivent normalement en arrière-plan.",
+ "es": "El iPhone y el iPad limitan lo que las apps pueden hacer en segundo plano. VniDrop mantiene una transferencia ya en curso el tiempo suficiente para terminarla y avisarte tras salir de la app, pero no puede seguir enviando o recibiendo por sí solo cuando lleva un rato en segundo plano. Para transferencias largas, mantén VniDrop abierto. En Mac, las transferencias continúan en segundo plano con normalidad.",
+ "it": "iPhone e iPad limitano ciò che le app possono fare in background. VniDrop mantiene attivo un trasferimento già in corso quanto basta per completarlo e avvisarti dopo che esci dall’app, ma non può continuare a inviare o ricevere da solo dopo un po’ in background. Per i trasferimenti lunghi, tieni VniDrop aperto. Su Mac i trasferimenti proseguono normalmente in background.",
+ "de": "iPhone und iPad schränken ein, was Apps im Hintergrund tun dürfen. VniDrop hält eine bereits laufende Übertragung lange genug am Leben, um sie abzuschließen und dich zu benachrichtigen, nachdem du die App verlässt, kann aber nicht von selbst weiter senden oder empfangen, wenn es länger im Hintergrund war. Lass VniDrop bei langen Übertragungen geöffnet. Auf dem Mac laufen Übertragungen im Hintergrund normal weiter.",
+ "pt": "O iPhone e o iPad limitam o que as apps podem fazer em segundo plano. O VniDrop mantém uma transferência já em curso ativa o tempo suficiente para terminar e notificá-lo depois de sair da app, mas não consegue continuar a enviar ou receber sozinho depois de algum tempo em segundo plano. Para transferências longas, mantenha o VniDrop aberto. No Mac, as transferências continuam normalmente em segundo plano.",
+ "pl": "iPhone i iPad ograniczają to, co aplikacje mogą robić w tle. VniDrop utrzymuje już trwający transfer wystarczająco długo, aby go dokończyć i powiadomić Cię po opuszczeniu aplikacji, ale nie może samodzielnie wysyłać ani odbierać po dłuższym czasie w tle. Przy długich transferach nie zamykaj VniDrop. Na Macu transfery są kontynuowane w tle normalnie.",
+ "nl": "iPhone en iPad beperken wat apps op de achtergrond mogen doen. VniDrop houdt een al lopende overdracht lang genoeg actief om deze te voltooien en je te melden nadat je de app verlaat, maar kan niet zelf blijven verzenden of ontvangen als het al een tijd op de achtergrond is. Houd VniDrop open bij lange overdrachten. Op de Mac gaan overdrachten normaal door op de achtergrond.",
+ "ru": "iPhone и iPad ограничивают действия приложений в фоне. VniDrop удерживает уже идущую передачу достаточно долго, чтобы завершить её и уведомить вас после выхода из приложения, но не может сам продолжать отправку или приём, пробыв некоторое время в фоне. Для долгих передач держите VniDrop открытым. На Mac передачи продолжаются в фоне как обычно."
+ }
+ },
+ "settings_ios_background_notice_title": {
+ "context": "Settings overview: title of the iOS/iPadOS background-limits notice. Apple platforms only.",
+ "targets": [
+ "apple"
+ ],
+ "translations": {
+ "en": "Background limits on iPhone & iPad",
+ "fr": "Limites en arrière-plan sur iPhone et iPad",
+ "es": "Límites en segundo plano en iPhone y iPad",
+ "it": "Limiti in background su iPhone e iPad",
+ "de": "Hintergrund-Grenzen auf iPhone & iPad",
+ "pt": "Limites em segundo plano no iPhone e iPad",
+ "pl": "Ograniczenia w tle na iPhonie i iPadzie",
+ "nl": "Achtergrondlimieten op iPhone en iPad",
+ "ru": "Ограничения фона на iPhone и iPad"
+ }
+ },
"settings_network_title": {
"context": "Settings overview row and Network settings screen title.",
"translations": {
From 2166aa9ce44a45441fcdf3ba0a58d78d03aedebd Mon Sep 17 00:00:00 2001
From: cdricms <36056008+cdricms@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:21:54 +0200
Subject: [PATCH 07/13] build(apple): ship TestFlight build 7 as Release
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
apple/project.yml | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/apple/project.yml b/apple/project.yml
index 2e05ee7..8f8fede 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -44,7 +44,7 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
- CURRENT_PROJECT_VERSION: "3"
+ CURRENT_PROJECT_VERSION: "7"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
@@ -114,3 +114,9 @@ schemes:
config: Debug
targets:
- VniDropTests
+ # TestFlight ships the Release build; make the Archive/Profile actions explicit
+ # so Product → Archive can never pick up a Debug configuration.
+ profile:
+ config: Release
+ archive:
+ config: Release
From 73bc87d3d161f915e1e64f11b4c007b1c4644d6b Mon Sep 17 00:00:00 2001
From: cdricms <36056008+cdricms@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:27:22 +0200
Subject: [PATCH 08/13] 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.
---
apple/VniDrop/Resources/VniDrop.entitlements | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/apple/VniDrop/Resources/VniDrop.entitlements b/apple/VniDrop/Resources/VniDrop.entitlements
index d2b9cb0..a404c90 100644
--- a/apple/VniDrop/Resources/VniDrop.entitlements
+++ b/apple/VniDrop/Resources/VniDrop.entitlements
@@ -2,10 +2,12 @@
-
+
com.apple.developer.nfc.readersession.formats
- NDEF
+ TAG
+ SUFeedURL
+ https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml
+ SUPublicEDKey
+ /vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M=
+ SUEnableAutomaticChecks
+
LSSupportsOpeningDocumentsInPlace
NFCReaderUsageDescription
diff --git a/apple/VniDrop/Resources/VniDropDirect.entitlements b/apple/VniDrop/Resources/VniDropDirect.entitlements
new file mode 100644
index 0000000..8df2838
--- /dev/null
+++ b/apple/VniDrop/Resources/VniDropDirect.entitlements
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/apple/project.yml b/apple/project.yml
index 8f8fede..faf3d7c 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -12,6 +12,15 @@ options:
macOS: "15.0"
createIntermediateGroups: true
+# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release
+# defaults, so both are re-listed here. `Release-Direct` is a release-type config
+# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store
+# `VniDrop` target ships under plain `Release`.
+configs:
+ Debug: debug
+ Release: release
+ Release-Direct: release
+
# Project-wide build settings (applied to every target/config).
settings:
base:
@@ -26,27 +35,44 @@ packages:
SFSafeSymbols:
url: https://github.com/SFSafeSymbols/SFSafeSymbols
from: "5.3.0"
+ # Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
+ # It is linked exclusively by the VniDropDirect target — SwiftPM links products
+ # per target, not per config, so keeping it off the App Store target is what
+ # guarantees the store binary never bundles a self-updater (App Store forbids it).
+ Sparkle:
+ url: https://github.com/sparkle-project/Sparkle
+ from: "2.9.4"
-targets:
- VniDrop:
+# Shared definition for the two shipping app targets. `VniDrop` (App Store /
+# TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same
+# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION
+# compile flag differ (set per target below).
+targetTemplates:
+ AppBase:
type: application
- supportedDestinations: [iOS, macOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
+ Release-Direct: Signing.xcconfig
sources:
- path: VniDrop
excludes:
- "Resources/Info.plist"
- "Resources/VniDrop.entitlements"
+ - "Resources/VniDropDirect.entitlements"
- "Resources/**/.DS_Store"
settings:
base:
+ PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
- CURRENT_PROJECT_VERSION: "7"
+ # Placeholder only — the real CFBundleVersion is stamped at build time as a
+ # UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every
+ # build is monotonic and self-describing (shown as "MARKETING_VERSION (build)").
+ CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
+ CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
# (the editor reads these build settings, not the manual plist).
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
@@ -59,12 +85,11 @@ targets:
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
configs:
- debug:
- CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release:
- CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Produce a dSYM in the archive so symbol upload succeeds.
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
+ release-direct:
+ DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
dependencies:
- package: VnidropCore
- package: SFSafeSymbols
@@ -86,6 +111,52 @@ targets:
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
exit 1
fi
+ postBuildScripts:
+ # Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built
+ # Info.plist before code signing. Runs for every build (Xcode GUI archive and
+ # CLI alike), so both the App Store and direct-download channels get a
+ # monotonic, meaningful build id. CI/reproducible builds can pin it via the
+ # VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version.
+ - name: Stamp build number (UTC timestamp)
+ basedOnDependencyAnalysis: false
+ script: |
+ build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}"
+ plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
+ if [ -f "$plist" ]; then
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist"
+ echo "Stamped CFBundleVersion = $build"
+ else
+ echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped"
+ fi
+
+targets:
+ # App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
+ VniDrop:
+ templates: [AppBase]
+ supportedDestinations: [iOS, macOS]
+
+ # 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]
+ 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
+ # The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
+ # (build-core.sh builds aarch64-apple-darwin only), so the direct build is
+ # Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
+ # link doesn't fail looking for x86_64 symbols.
+ ARCHS: arm64
+ dependencies:
+ - package: Sparkle
VniDropTests:
type: bundle.unit-test
@@ -93,6 +164,7 @@ targets:
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
+ Release-Direct: Signing.xcconfig
sources:
- path: Tests
settings:
@@ -120,3 +192,15 @@ schemes:
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
diff --git a/apple/scripts/ExportOptions-DeveloperID.plist b/apple/scripts/ExportOptions-DeveloperID.plist
new file mode 100644
index 0000000..27229b8
--- /dev/null
+++ b/apple/scripts/ExportOptions-DeveloperID.plist
@@ -0,0 +1,19 @@
+
+
+
+
+
+