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": {