From 516c4ace8496424fbe480be2a16ab38c14e4e296 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:06:43 +0200 Subject: [PATCH 01/13] 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. --- LICENSE | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index d645695..c4ca95b 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,8 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 VniDrop + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From a8a168ffdedd95f74e4ec9d7e5cbfed9dc5c965f Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:06:44 +0200 Subject: [PATCH 02/13] 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. --- apple/VniDrop/Resources/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist index a2613e4..b204cdf 100644 --- a/apple/VniDrop/Resources/Info.plist +++ b/apple/VniDrop/Resources/Info.plist @@ -20,6 +20,8 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName VniDrop + ITSAppUsesNonExemptEncryption + LSMultipleInstancesProhibited From 9b8d66f97dc90b8cd1d079ec2742429131645035 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:06:44 +0200 Subject: [PATCH 03/13] 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. --- .gitattributes | 1 + packaging/apple/.gitignore | 7 +++++++ packaging/apple/AppStore.af | 3 +++ 3 files changed, 11 insertions(+) create mode 100644 .gitattributes create mode 100644 packaging/apple/.gitignore create mode 100644 packaging/apple/AppStore.af diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a729e15 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.af filter=lfs diff=lfs merge=lfs -text diff --git a/packaging/apple/.gitignore b/packaging/apple/.gitignore new file mode 100644 index 0000000..4080ad7 --- /dev/null +++ b/packaging/apple/.gitignore @@ -0,0 +1,7 @@ +# Exported screenshots (large; regenerated from the design source) — not tracked. +*.jpg +*.jpeg + +# macOS / editor junk +.DS_Store +*~lock~ diff --git a/packaging/apple/AppStore.af b/packaging/apple/AppStore.af new file mode 100644 index 0000000..45a0299 --- /dev/null +++ b/packaging/apple/AppStore.af @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2aa9a22a914aa9503f202cf2f2336e9dc236a7aa2cb09fc52a6f1ac61fc90ca7 +size 10653377 From ea2f8b1cc72d6d6ca365ac00d72a4e2ddc8503f6 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:24:04 +0200 Subject: [PATCH 04/13] 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. --- .../Resources/AppIcon.icon/Assets/Drop.svg | 8 +++ .../Resources/AppIcon.icon/Assets/Mask.svg | 17 +++++ .../Resources/AppIcon.icon/Assets/U.svg | 8 +++ .../VniDrop/Resources/AppIcon.icon/icon.json | 65 ++++++++++++++++++ .../AppIcon.appiconset/Contents.json | 7 -- .../AppIcon.appiconset/app-icon.png | Bin 50347 -> 0 bytes 6 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg create mode 100644 apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg create mode 100644 apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg create mode 100644 apple/VniDrop/Resources/AppIcon.icon/icon.json delete mode 100644 apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/app-icon.png diff --git a/apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg b/apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg new file mode 100644 index 0000000..f6b4544 --- /dev/null +++ b/apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg b/apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg new file mode 100644 index 0000000..65754f3 --- /dev/null +++ b/apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg b/apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg new file mode 100644 index 0000000..3828215 --- /dev/null +++ b/apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apple/VniDrop/Resources/AppIcon.icon/icon.json b/apple/VniDrop/Resources/AppIcon.icon/icon.json new file mode 100644 index 0000000..e22489e --- /dev/null +++ b/apple/VniDrop/Resources/AppIcon.icon/icon.json @@ -0,0 +1,65 @@ +{ + "features" : [ + "refractivity" + ], + "fill" : { + "linear-gradient" : [ + "extended-gray:1.00000,1.00000", + "display-p3:0.55433,0.59923,0.92884,1.00000" + ] + }, + "groups" : [ + { + "blend-mode" : "normal", + "blur-material" : null, + "layers" : [ + { + "image-name" : "Mask.svg", + "name" : "Mask" + } + ], + "lighting" : "individual", + "refractivity" : { + "depth" : 0.5, + "enabled" : true, + "strength" : 0 + }, + "shadow" : { + "kind" : "neutral", + "opacity" : 0.6 + }, + "specular" : true, + "translucency" : { + "enabled" : true, + "value" : 0.8 + } + }, + { + "layers" : [ + { + "image-name" : "Drop.svg", + "name" : "Drop" + }, + { + "image-name" : "U.svg", + "name" : "U" + } + ], + "lighting" : "combined", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.6 + }, + "translucency" : { + "enabled" : true, + "value" : 0.4 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index bf7455d..0000000 --- a/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -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 } -} diff --git a/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/app-icon.png b/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/app-icon.png deleted file mode 100644 index 91e521f746ee0fd0d011a0f99ae5fd4956e1c67a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50347 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 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 @@ + + + + + + method + developer-id + signingStyle + manual + + teamID + ${DEVELOPMENT_TEAM} + + diff --git a/apple/scripts/build-dmg.sh b/apple/scripts/build-dmg.sh new file mode 100755 index 0000000..6504a7f --- /dev/null +++ b/apple/scripts/build-dmg.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Builds the direct-download macOS artifact: a Developer ID–signed, notarized +# .dmg of the VniDropDirect target (the Sparkle-enabled build). Produces: +# - apple/dist/VniDrop-.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 [version] +# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag. +# +# 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" + +# --- Resolve version (arg > git tag > project MARKETING_VERSION) ------------- +resolve_version() { + local v="${1:-}" + if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + v="${GITHUB_REF_NAME#v}" + fi + if [ -z "$v" ]; then + v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)" + fi + if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2 + exit 1 + fi + printf '%s' "$v" +} +VERSION="$(resolve_version "${1:-}")" + +# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's +# "Stamp build number" build phase. Pin it here (one value for the whole archive) +# so the app, DMG, and appcast all agree; Sparkle compares it to order updates. +BUILD_NUMBER="$(date -u +%y%m%d.%H%M)" +export VNIDROP_BUILD="$BUILD_NUMBER" + +# --- 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:-}" + +# --- 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" +( 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; } + +# --- 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)" + xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait + 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")" +echo "==> Done." +echo " dmg: $DMG" +echo " version: $VERSION" +echo " size: $SIZE bytes" diff --git a/apple/scripts/generate-appcast.sh b/apple/scripts/generate-appcast.sh new file mode 100755 index 0000000..762bd43 --- /dev/null +++ b/apple/scripts/generate-appcast.sh @@ -0,0 +1,68 @@ +#!/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 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 [version] +# +# 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="${1:-}" +if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + VERSION="${GITHUB_REF_NAME#v}" +fi +[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; } + +# 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/" diff --git a/localization/strings.json b/localization/strings.json index e44bfd8..2196c4f 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -4629,6 +4629,23 @@ "ru": "Поделиться" } }, + "updates_check": { + "context": "macOS app menu item that checks for a new version via Sparkle. Direct-download (.dmg) build only; never shown in the App Store build.", + "targets": [ + "apple" + ], + "translations": { + "en": "Check for Updates…", + "fr": "Rechercher les mises à jour…", + "es": "Buscar actualizaciones…", + "it": "Cerca aggiornamenti…", + "de": "Nach Updates suchen…", + "pt": "Procurar atualizações…", + "pl": "Sprawdź aktualizacje…", + "nl": "Zoeken naar updates…", + "ru": "Проверить наличие обновлений…" + } + }, "value_unavailable": { "context": "Placeholder shown when a device-info or metadata value can't be read.", "translations": { diff --git a/packaging/homebrew/tap-README.md b/packaging/homebrew/tap-README.md new file mode 100644 index 0000000..9296b54 --- /dev/null +++ b/packaging/homebrew/tap-README.md @@ -0,0 +1,54 @@ +# homebrew-vnidrop + +Homebrew tap for [VniDrop](https://github.com/sudosylabs/vnidrop) — direct, +private device-to-device file and folder transfer for macOS. + +> This repo only holds the Homebrew **cask**. The app itself lives at +> [sudosylabs/vnidrop](https://github.com/sudosylabs/vnidrop). The cask here is +> updated automatically by VniDrop's release pipeline on each tagged release. + +## Install + +```sh +brew tap sudosylabs/vnidrop +brew install --cask vnidrop +``` + +Or in one line: + +```sh +brew install --cask sudosylabs/vnidrop/vnidrop +``` + +## Update + +VniDrop updates itself in-app via [Sparkle](https://sparkle-project.org), so you +normally don't need to do anything. To update through Homebrew instead: + +```sh +brew upgrade --cask vnidrop +``` + +## Uninstall + +```sh +brew uninstall --cask vnidrop +``` + +Add `--zap` to also remove VniDrop's application support, cache, and preference +files: + +```sh +brew uninstall --zap --cask vnidrop +``` + +## Requirements + +- macOS 15 (Sequoia) or later, Apple Silicon. + +## What you get + +The cask installs the Developer ID–signed, notarized `VniDrop.app` from the +matching [GitHub Release](https://github.com/sudosylabs/vnidrop/releases). App +Store users should install from the Mac App Store instead — that build does not +include the Sparkle self-updater. diff --git a/packaging/homebrew/vnidrop.rb b/packaging/homebrew/vnidrop.rb new file mode 100644 index 0000000..f81fca9 --- /dev/null +++ b/packaging/homebrew/vnidrop.rb @@ -0,0 +1,36 @@ +# Homebrew cask for the direct-download (notarized .dmg) macOS build. +# +# This file is the source template. The Apple release workflow substitutes the +# version + sha256 for each release and pushes the result to the tap repo +# (sudosylabs/homebrew-vnidrop, path Casks/vnidrop.rb). Users then install with: +# brew install --cask sudosylabs/vnidrop/vnidrop +# +# `auto_updates true` tells Homebrew that the app updates itself via Sparkle, so +# `brew upgrade` won't fight the in-app updater. +cask "vnidrop" do + version "0.0.0" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + + url "https://github.com/sudosylabs/vnidrop/releases/download/v#{version}/VniDrop-#{version}.dmg", + verified: "github.com/sudosylabs/vnidrop/" + name "VniDrop" + desc "Direct device-to-device file and folder transfer over the network" + homepage "https://github.com/sudosylabs/vnidrop" + + livecheck do + url :url + strategy :github_latest + end + + auto_updates true + depends_on arch: :arm64 + depends_on macos: ">= :sequoia" + + app "VniDrop.app" + + zap trash: [ + "~/Library/Application Support/com.vnidrop.app", + "~/Library/Caches/com.vnidrop.app", + "~/Library/Preferences/com.vnidrop.app.plist", + ] +end From a0ebd7c71bc679b9d0c95a7a33b8260271050e7c Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:01:01 +0200 Subject: [PATCH 11/13] fix(apple): restore macOS approval modal and sandboxed file sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apple/VniDrop/App/RootView.swift | 28 ++++++++++++++++--- apple/VniDrop/Core/FileSystemService.swift | 4 +++ .../Features/Approvals/ApprovalModal.swift | 6 +++- .../Platform/FileSystemService+macOS.swift | 16 +++++++++++ apple/VniDrop/Platform/PlatformPickers.swift | 8 +++++- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 25f715c..dacdc03 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -14,6 +14,10 @@ struct RootView: View { @Environment(\.scenePhase) private var scenePhase + /// Drives the approval sheet; toggled from the pending-approval `onChange` so the + /// presentation can be deferred until the Share/QR sheet has dismissed on macOS. + @State private var showApproval = false + init(dependencies: AppDependencies) { let graph = AppGraph(dependencies: dependencies) _graph = StateObject(wrappedValue: graph) @@ -58,6 +62,7 @@ struct RootView: View { navigation(windowClass: windowClass) SnackbarHost(controller: messages) ApprovalModalHost( + isPresented: $showApproval, state: approvals.state, onAccept: approvals.accept, onRefuse: approvals.refuse @@ -98,11 +103,26 @@ struct RootView: View { 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. + // A pending approval is a blocking modal. Close the sender's detail panel + // (e.g. the Share/QR sheet) first, then present the approval sheet — but on + // macOS a sheet presented while another is still dismissing is silently + // dropped, so defer the presentation until that dismissal finishes. .onChange(of: approvals.state.current?.id) { _, id in - if id != nil { sendModel.closeDetailPanel() } + guard id != nil else { showApproval = false; return } + let wasShowingSheet = sendModel.state.detailPanel != nil + sendModel.closeDetailPanel() + #if os(macOS) + if wasShowingSheet { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + if approvals.state.current != nil { showApproval = true } + } + } else { + showApproval = true + } + #else + _ = wasShowingSheet + showApproval = true + #endif } #if os(macOS) // macOS keeps `scenePhase == .active` even when the app loses focus, so diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index f50916e..96b218d 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable { var isTemporaryCopy: Bool = false /// When true, `value` is a directory (path or security-scoped folder URL). var isDirectory: Bool = false + /// macOS sandbox: a security-scoped bookmark captured at pick time so access to + /// `value` can be re-acquired when the core imports the file (the picker's own + /// scope ends immediately). Nil on iOS (which copies into the container instead). + var securityScopeBookmark: Data? = nil var id: String { value } } diff --git a/apple/VniDrop/Features/Approvals/ApprovalModal.swift b/apple/VniDrop/Features/Approvals/ApprovalModal.swift index 53a6e01..7123dbe 100644 --- a/apple/VniDrop/Features/Approvals/ApprovalModal.swift +++ b/apple/VniDrop/Features/Approvals/ApprovalModal.swift @@ -5,13 +5,17 @@ import SFSafeSymbols /// be swiped away. The endpoint id is the trusted identity; display names are /// peer-provided. struct ApprovalModalHost: View { + /// Driven by the host so presentation can be deferred until any competing sheet + /// (the Share/QR drawer) has finished dismissing — macOS silently drops a sheet + /// presented while another is still animating out. + @Binding var isPresented: Bool let state: ApprovalState let onAccept: (String) -> Void let onRefuse: (String) -> Void var body: some View { Color.clear - .sheet(isPresented: .constant(state.current != nil)) { + .sheet(isPresented: $isPresented) { if let request = state.current { ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse) .interactiveDismissDisabled(true) diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index 55628ec..c2130d2 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -44,6 +44,22 @@ struct MacFileSystemService: FileSystemService { guard !files.isEmpty else { return .failure(InvitationError.message("Select at least one file to share")) } + // Re-acquire security-scoped access to every picked source (from the bookmark + // captured at pick time) and hold it across the whole share call. The core + // imports the bytes during shareFiles(), so access only needs to survive that + // call; without this, the import fails with EPERM under the App Store sandbox. + var scopedURLs: [URL] = [] + for file in files { + guard let bookmark = file.securityScopeBookmark else { continue } + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, options: .withSecurityScope, + relativeTo: nil, bookmarkDataIsStale: &stale + ), url.startAccessingSecurityScopedResource() else { continue } + scopedURLs.append(url) + } + defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } } + let sources = files.map { ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) } diff --git a/apple/VniDrop/Platform/PlatformPickers.swift b/apple/VniDrop/Platform/PlatformPickers.swift index 9ecb917..184b45c 100644 --- a/apple/VniDrop/Platform/PlatformPickers.swift +++ b/apple/VniDrop/Platform/PlatformPickers.swift @@ -107,9 +107,15 @@ enum PickerSupport { ) #else let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) } + // Capture a security-scoped bookmark while the picker's scope is still held, + // so the core can re-acquire access to open the file at import time (under + // the App Store sandbox). Non-sandboxed builds don't need it but it's harmless. + let bookmark = try? url.bookmarkData( + options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil + ) return PickedShareFile( value: url.path, displayName: url.lastPathComponent, sizeBytes: size, - isTemporaryCopy: false, isDirectory: isDirectory + isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark ) #endif } From cbb535d99820e76b48203430e37b1229a84db48d Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:05:35 +0200 Subject: [PATCH 12/13] 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. --- .gitignore | 1 + apple/RELEASE-MACOS.md | 155 ----------------------------------------- 2 files changed, 1 insertion(+), 155 deletions(-) delete mode 100644 apple/RELEASE-MACOS.md diff --git a/.gitignore b/.gitignore index d3bdd2e..5b43b44 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ config.override.mk # Local design export scratch output/ .screenshots +apple/RELEASE-MACOS.md diff --git a/apple/RELEASE-MACOS.md b/apple/RELEASE-MACOS.md deleted file mode 100644 index 40b5ce4..0000000 --- a/apple/RELEASE-MACOS.md +++ /dev/null @@ -1,155 +0,0 @@ -# Releasing VniDrop for macOS - -VniDrop ships on macOS through **two independent channels**: - -| Channel | Target / config | Signing | Updates | -| --- | --- | --- | --- | -| **Mac App Store / TestFlight** | `VniDrop` / `Release` | Apple Distribution | App Store | -| **Direct download (.dmg)** | `VniDropDirect` / `Release-Direct` | Developer ID + notarization | Sparkle | - -The direct build adds the **Sparkle** auto-updater, gated behind the -`DIRECT_DISTRIBUTION` compile flag so the App Store binary never links or ships a -self-updater (which Apple forbids). Because Swift Package Manager links products -per *target*, the isolation is a dedicated `VniDropDirect` target — not just a -build configuration. - -This document covers the **direct-download** channel. The App Store channel goes -through Xcode Organizer / App Store Connect as before (see -`RELEASE-TESTFLIGHT.fr.md`). - -## Versioning - -Both channels use Apple's two-field convention, shown as `MARKETING_VERSION (build)`: - -- **`CFBundleShortVersionString`** = `MARKETING_VERSION` — the human `X.Y.Z` version - (from the git tag for a release). This is what users, the DMG filename, the - GitHub Release, and the Homebrew cask all use. -- **`CFBundleVersion`** = a **UTC `YYMMDD.HHMM` timestamp**, stamped into the built - Info.plist by the "Stamp build number" build phase (`apple/project.yml`). It fires - for every build — Xcode Organizer archive and CLI alike — so each build is - monotonic and self-describing. Sparkle compares this field to order updates, and - App Store Connect requires each upload's build to exceed the previous one; a - timestamp satisfies both automatically. `build-dmg.sh` pins one timestamp per - archive (via `VNIDROP_BUILD`) so the app, DMG, and appcast agree. - -No build number is maintained by hand. - ---- - -## One-time setup - -### 1. Developer ID Application certificate - -Direct distribution requires a **Developer ID Application** certificate (distinct -from the *Apple Distribution* cert used for the App Store). In Xcode ▸ Settings ▸ -Accounts ▸ Manage Certificates ▸ **+** ▸ *Developer ID Application*. Confirm it's -in the keychain: - -```bash -security find-identity -v -p codesigning | grep "Developer ID Application" -``` - -### 2. Notarization credentials (App Store Connect API key) - -Create an API key at App Store Connect ▸ Users and Access ▸ Integrations ▸ Keys -(role: *Developer*). Download the `AuthKey_XXXX.p8`, and note the **Key ID** and -**Issuer ID**. Store a local notarytool profile: - -```bash -xcrun notarytool store-credentials vnidrop-notary \ - --key /path/to/AuthKey_XXXX.p8 \ - --key-id \ - --issuer -``` - -### 3. Sparkle EdDSA signing key - -Generate the update-signing key once (private key stored in the login keychain): - -```bash -# Sparkle's generate_keys, from the downloaded Sparkle release (bin/generate_keys) -./bin/generate_keys -``` - -It prints the **public** key. Put it in `apple/VniDrop/Resources/Info.plist` as -`SUPublicEDKey`, replacing `REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY`. Export the -**private** key for CI: - -```bash -./bin/generate_keys -x sparkle_ed_private_key # writes the private key file -``` - -### 4. Homebrew tap repo - -Create an empty GitHub repo **`sudosylabs/homebrew-vnidrop`** (a `Casks/` folder -is enough). The release workflow pushes `Casks/vnidrop.rb` there. Users install: - -```bash -brew install --cask sudosylabs/vnidrop/vnidrop -``` - -### 5. CI secrets - -Add these to the `sudosylabs/vnidrop` repo (Settings ▸ Secrets ▸ Actions): - -| Secret | Value | -| --- | --- | -| `DEVELOPER_ID_CERT_P12` | base64 of the exported Developer ID `.p12` | -| `DEVELOPER_ID_CERT_PASSWORD` | password for that `.p12` | -| `NOTARY_API_KEY` | base64 of `AuthKey_XXXX.p8` | -| `NOTARY_KEY_ID` | App Store Connect key ID | -| `NOTARY_ISSUER` | App Store Connect issuer ID | -| `SPARKLE_ED_PRIVATE_KEY` | contents of the exported Sparkle private key file | -| `HOMEBREW_TAP_TOKEN` | PAT with write access to `homebrew-vnidrop` | - -> `SUFeedURL` in Info.plist points at -> `https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml`. -> GitHub's `/releases/latest/download/` path always redirects to the newest -> non-prerelease release's asset, so no GitHub Pages or repo commits are needed. - ---- - -## Per-release flow - -1. Bump `MARKETING_VERSION` (and `CURRENT_PROJECT_VERSION`) in `apple/project.yml` - if needed, commit to `master`. -2. Tag and push: - - ```bash - git tag v0.2.0 - git push origin v0.2.0 - ``` - -3. `.github/workflows/apple-release.yml` then: - - builds the Rust core (release), regenerates the project, - - archives + exports the `VniDropDirect` target (Developer ID, hardened runtime), - - builds, signs, **notarizes and staples** `VniDrop-.dmg`, - - runs `generate_appcast` to produce `appcast.xml` (enclosure → the release DMG), - - creates the GitHub Release with both assets, and - - renders + pushes the Homebrew cask to the tap. - -Existing installs pick up the new version automatically via Sparkle (feed → -latest release's `appcast.xml`); `brew upgrade` respects `auto_updates true`. - ---- - -## Building a DMG locally - -```bash -# Signed DMG (skips notarization unless NOTARY_PROFILE is set): -make build-apple-dmg VERSION=0.2.0 - -# Full signed + notarized DMG: -NOTARY_PROFILE=vnidrop-notary make build-apple-dmg VERSION=0.2.0 -``` - -Output: `apple/dist/VniDrop-.dmg`. See `apple/scripts/build-dmg.sh` for -the environment variables (`DEVELOPER_ID_APP`, `DEVELOPMENT_TEAM`, -`NOTARY_PROFILE`). Generate the appcast with -`apple/scripts/generate-appcast.sh `. - -To compile-check the direct target without signing: - -```bash -make build-apple-macos-direct -``` From 4730554c2c12fbd03f09f3934ce50013fbe28b5a Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:07:19 +0200 Subject: [PATCH 13/13] refactor(apple): make InvitationError typed and localize NFC prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apple/.swiftlint.yml | 15 ++++++++ apple/Tests/UiFeedbackTests.swift | 23 +++++++----- apple/VniDrop/Core/CoreRepository.swift | 4 +- .../Core/ExternalInvitationController.swift | 35 ++++++++++++++---- apple/VniDrop/Core/FileSystemService.swift | 2 +- apple/VniDrop/Features/Send/SendModel.swift | 2 +- .../Features/Settings/BugReportService.swift | 2 +- .../Features/Settings/SettingsModel.swift | 4 +- .../Platform/FileSystemService+iOS.swift | 8 ++-- .../Platform/FileSystemService+macOS.swift | 2 +- .../ReceiveInvitationActions+iOS.swift | 22 +++++------ .../ReceiveInvitationActions+macOS.swift | 6 +-- .../Platform/TransferShareActions+iOS.swift | 18 ++++----- .../Platform/TransferShareActions+macOS.swift | 6 +-- .../VniDrop/UI/Feedback/UserFacingError.swift | 37 +++++++++++++++++++ 15 files changed, 130 insertions(+), 56 deletions(-) diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index b9a3f8e..66fd294 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -32,3 +32,18 @@ custom_rules: 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 diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift index fdbbfbe..9250f79 100644 --- a/apple/Tests/UiFeedbackTests.swift +++ b/apple/Tests/UiFeedbackTests.swift @@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase { func testErrorSuppressesUserCancellation() { let c = UiMessageController() - c.error(InvitationError.message("QR scanning was cancelled")) + c.error(InvitationError.cancelled) XCTAssertNil(c.current) // cancellations are swallowed } func testErrorShowsNonCancellation() { let c = UiMessageController() - c.error(InvitationError.message("The transfer was refused")) + c.error(InvitationError.raw("The transfer was refused")) XCTAssertEqual(c.current?.tone, .error) } } @@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase { final class UserFacingErrorTests: XCTestCase { func testIsUserCancellation() { - XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) - XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) - XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) + XCTAssertTrue(InvitationError.cancelled.isUserCancellation) + XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation) + XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation) } func testToUiTextMapsKnownReasons() { - XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) - XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) - XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty)) - XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera)) + // Typed cases map directly at the UI boundary. + XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty)) + XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera)) + XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc)) + // Dynamic `.raw` payloads still fall through the substring hints. + XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) + XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) } func testToUiTextFallsBackToGeneric() { - XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) + XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) } func testToUiTextMapsTypedTransferFailures() { diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index ed9c81a..997feba 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -156,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway { return .failure(CoreNetworkLifecycleError.transitionInProgress) } guard !sources.isEmpty else { - return .failure(InvitationError.message("Select at least one file to share")) + return .failure(InvitationError.shareEmpty) } return await runCore { let result = try self.requireCore().shareFiles( @@ -353,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway { private nonisolated func requireCore() throws -> VnidropCore { guard let core = self.core else { - throw InvitationError.message("Initialize the core first.") + throw InvitationError.coreNotInitialized } return core } diff --git a/apple/VniDrop/Core/ExternalInvitationController.swift b/apple/VniDrop/Core/ExternalInvitationController.swift index d0a82cc..2850e39 100644 --- a/apple/VniDrop/Core/ExternalInvitationController.swift +++ b/apple/VniDrop/Core/ExternalInvitationController.swift @@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject { } func reportOpenFailure(message: String) { - continuation?.yield(.failure(InvitationError.message(message))) + continuation?.yield(.failure(InvitationError.raw(message))) } } +/// Semantic, UI-agnostic invitation/transfer failures. Cases carry no display +/// text: `Error.toUiText()` (UI layer) maps each case to a localized `L10n` key, +/// so there are no free-form English strings to keep in sync or substring-match. +/// `.raw` is the escape hatch for genuinely dynamic system/core messages (e.g. a +/// `CoreNFC` `localizedDescription` or a picker's failure reason), never shown +/// verbatim — it is still routed through `reasonHints`. enum InvitationError: LocalizedError { case empty case tooLarge case invalidEncoding - case message(String) + case shareEmpty + case cancelled + case coreNotInitialized + case unsupportedOperation + case noWindowAvailable + case viewControllerUnavailable + case filesystemUnavailable + case invalidInvitationURL + case nfcUnavailable + case nfcFailed + case cameraUnavailable + case qrUnavailable + case bugReportingUnavailable + case selectionFailed + case deleteRecordsFailed + case raw(String) + /// Developer/log-facing only — never surfaced to users. Derived from the case + /// so there are no hand-written English blobs; `.raw` passes its payload through. var errorDescription: String? { - switch self { - case .empty: return "The invitation is empty" - case .tooLarge: return "The invitation is too large" - case .invalidEncoding: return "The invitation is not valid text" - case .message(let m): return m - } + if case .raw(let reason) = self { return reason } + return String(describing: self) } } diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index 96b218d..401d388 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -52,7 +52,7 @@ extension FileSystemService { func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result { - .failure(InvitationError.message("Revealing the receive folder is not supported")) + .failure(InvitationError.unsupportedOperation) } func discardPickedFiles(_ files: [PickedShareFile]) async {} diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index eda9c95..951bb77 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -154,7 +154,7 @@ final class SendModel: ObservableObject { } func onFilePickFailed(_ reason: String) { - messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason)) + messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason)) } func clearSelectedSource() { diff --git a/apple/VniDrop/Features/Settings/BugReportService.swift b/apple/VniDrop/Features/Settings/BugReportService.swift index 5bdfad9..17be101 100644 --- a/apple/VniDrop/Features/Settings/BugReportService.swift +++ b/apple/VniDrop/Features/Settings/BugReportService.swift @@ -20,7 +20,7 @@ protocol BugReportService { /// Offline-safe no-op used until the diagnostics transport is configured. struct NoopBugReportService: BugReportService { func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result { - .failure(InvitationError.message("Bug reporting is not configured")) + .failure(InvitationError.bugReportingUnavailable) } func previewLogBytes() async -> Int { 0 } } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index b5d3c3f..8158926 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -206,7 +206,7 @@ final class SettingsModel: ObservableObject { } func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) } - func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) } + func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) } func resetReceiveFolder() { preferences.resetReceiveFolder() } /// Whether the current receive folder is the platform default (so the reset @@ -475,7 +475,7 @@ final class SettingsModel: ObservableObject { loadStorageUsage() messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success)) } else { - messages.error(InvitationError.message("Could not delete \(failures) transfer records")) + messages.error(InvitationError.deleteRecordsFailed) } } } diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index b0fcc51..5827dc9 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result { guard canRevealReceiveFolder(folder) else { - return .failure(InvitationError.message("The receive folder is not VniDrop Documents")) + return .failure(InvitationError.filesystemUnavailable) } guard let url = URL(string: "shareddocuments://\(folder.value)") else { - return .failure(InvitationError.message("The Files location URL is unavailable")) + return .failure(InvitationError.filesystemUnavailable) } let opened = await withCheckedContinuation { continuation in DispatchQueue.main.async { @@ -44,7 +44,7 @@ struct IosFileSystemService: FileSystemService { } } } - return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files")) + return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable) } func discardPickedFiles(_ files: [PickedShareFile]) async { @@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService { accessPolicy: ShareAccessPolicy ) async -> Result { guard !files.isEmpty else { - return .failure(InvitationError.message("Select at least one file to share")) + return .failure(InvitationError.shareEmpty) } let sources = files.map { $0.toIosShareSource() } return await repository.shareSources( diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index c2130d2..9bf6135 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -42,7 +42,7 @@ struct MacFileSystemService: FileSystemService { accessPolicy: ShareAccessPolicy ) async -> Result { 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 diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift index e0610d0..b83a445 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift @@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID picker.delegate = self picker.modalPresentationStyle = .formSheet guard let presenter = topPresenter() else { - return onResult(.failure(InvitationError.message("Could not find an iOS view controller"))) + return onResult(.failure(InvitationError.viewControllerUnavailable)) } presenter.present(picker, animated: true) } @@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID func scanQrCode(onResult: @escaping (Result) -> Void) { cancel() guard let presenter = topPresenter() else { - return onResult(.failure(InvitationError.message("Could not find an iOS view controller"))) + return onResult(.failure(InvitationError.viewControllerUnavailable)) } ensureCameraAccess { [weak self] granted in guard let self else { return } guard granted else { - return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes"))) + return onResult(.failure(InvitationError.cameraUnavailable)) } let scanner = QrScannerViewController { result in self.qrController = nil @@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID func readNfcInvitation(onResult: @escaping (Result) -> Void) { cancel() guard NFCNDEFReaderSession.readingAvailable else { - return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device"))) + return onResult(.failure(InvitationError.nfcUnavailable)) } let reader = InvitationNfcReader { [weak self] result in self?.nfcReader = nil @@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID let result = documentResult documentResult = nil result?(Result { - guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") } + guard let url = urls.first else { throw InvitationError.invalidInvitationURL } let started = url.startAccessingSecurityScopedResource() defer { if started { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) @@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb } func cancelScan() { - finish(.failure(InvitationError.message("QR scanning was cancelled"))) + finish(.failure(InvitationError.cancelled)) } private func configureSession() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { - return finish(.failure(InvitationError.message("No camera is available"))) + return finish(.failure(InvitationError.cameraUnavailable)) } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { - return finish(.failure(InvitationError.message("Could not configure the QR scanner"))) + return finish(.failure(InvitationError.cameraUnavailable)) } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) @@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec func start() { let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true) - reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag" + reader.alertMessage = String(localized: L10n.Receive.nfcWaiting) session = reader reader.begin() } @@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { if finished { return } let cancelled = (error as NSError).code == 200 - finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription))) + finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription))) } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { @@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec .flatMap { $0.records } .compactMap { payloadAsInvitation($0) } .first - guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") } + guard let ticket else { throw InvitationError.nfcFailed } return ticket } session.invalidate() diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift index 2586941..e2ab9f5 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift @@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions { } panel.begin { response in guard response == .OK, let url = panel.url else { - onResult(.failure(InvitationError.message("cancelled"))) + onResult(.failure(InvitationError.cancelled)) return } onResult(Result { @@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions { } func scanQrCode(onResult: @escaping (Result) -> Void) { - onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS"))) + onResult(.failure(InvitationError.qrUnavailable)) } func readNfcInvitation(onResult: @escaping (Result) -> Void) { - onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) + onResult(.failure(InvitationError.nfcUnavailable)) } func cancel() {} diff --git a/apple/VniDrop/Platform/TransferShareActions+iOS.swift b/apple/VniDrop/Platform/TransferShareActions+iOS.swift index c9fe5fd..11e090f 100644 --- a/apple/VniDrop/Platform/TransferShareActions+iOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+iOS.swift @@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result) -> Void) { cancelNfcWrite() guard NFCNDEFReaderSession.readingAvailable else { - onResult(.failure(InvitationError.message("NFC is unavailable on this device"))) + onResult(.failure(InvitationError.nfcUnavailable)) return } let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in @@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions { @MainActor private func present(_ controller: UIViewController) throws { guard let presenter = topPresenter() else { - throw InvitationError.message("Could not find an iOS view controller") + throw InvitationError.viewControllerUnavailable } presenter.present(controller, animated: true) } @@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec func start() { let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false) - reader.alertMessage = "Hold your iPhone near a writable NFC tag" + reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting) session = reader reader.begin() } @@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { if finished { return } let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled - finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription))) + finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription))) } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {} func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { guard let firstTag = tags.first else { - return finish(.failure(InvitationError.message("No NFC tag was detected"))) + return finish(.failure(InvitationError.nfcFailed)) } // CoreNFC completion handlers run on the session's `.main` queue; these // framework values are safe to use there. @@ -109,18 +109,18 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec if let queryError { return self.finish(.failure(queryError)) } switch status { case .notSupported: - self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF"))) + self.finish(.failure(InvitationError.nfcFailed)) case .readOnly: - self.finish(.failure(InvitationError.message("This NFC tag is read-only"))) + self.finish(.failure(InvitationError.nfcFailed)) default: guard let message = self.invitationMessage() else { - return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC"))) + return self.finish(.failure(InvitationError.nfcFailed)) } tag.writeNDEF(message) { writeError in if let writeError { self.finish(.failure(writeError)) } else { - session.alertMessage = "Invitation written" + session.alertMessage = String(localized: L10n.Transfer.nfcWritten) session.invalidate() self.finish(.success(())) } diff --git a/apple/VniDrop/Platform/TransferShareActions+macOS.swift b/apple/VniDrop/Platform/TransferShareActions+macOS.swift index 20878b9..7854f42 100644 --- a/apple/VniDrop/Platform/TransferShareActions+macOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+macOS.swift @@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions { panel.allowedContentTypes = [] panel.begin { response in guard response == .OK, let url = panel.url else { - onResult(.failure(InvitationError.message("cancelled"))) + onResult(.failure(InvitationError.cancelled)) return } onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) }) @@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions { do { let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName) guard let view = NSApp.keyWindow?.contentView else { - onResult(.failure(InvitationError.message("No window available"))) + onResult(.failure(InvitationError.noWindowAvailable)) return } let picker = NSSharingServicePicker(items: [url]) @@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions { } func writeInvitationToNfc(ticket: String, onResult: @escaping (Result) -> Void) { - onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) + onResult(.failure(InvitationError.nfcUnavailable)) } func cancelNfcWrite() {} diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift index 77cab8d..25f1ede 100644 --- a/apple/VniDrop/UI/Feedback/UserFacingError.swift +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -5,6 +5,9 @@ import VnidropCore /// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs. extension Error { func toUiText() -> UiText { + if let invitation = self as? InvitationError { + return invitation.uiText + } if let vni = self as? VnidropError { switch vni { case .Ticket: @@ -40,6 +43,7 @@ extension Error { /// True when the user intentionally backed out of a flow. var isUserCancellation: Bool { + if let invitation = self as? InvitationError, case .cancelled = invitation { return true } if let vni = self as? VnidropError, case .Cancelled = vni { return true } let haystack = technicalDetail.lowercased() if haystack.isEmpty { @@ -76,6 +80,39 @@ 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.