diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index f9aedba1..d1b9de3b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -92,6 +92,8 @@ private func wn_install_ios_background_keyring_store_with_access_group( switch call.method { case "getAppGroupContainerPath": result(self.appGroupContainerPath()) + case "applyDataProtection": + result(self.applyDataProtection(arguments: call.arguments)) default: result(FlutterMethodNotImplemented) } @@ -189,6 +191,16 @@ private func wn_install_ios_background_keyring_store_with_access_group( trigger == "invite" || trigger == "group_invite" || trigger == "GroupInvite" } + private func applyDataProtection(arguments: Any?) -> Bool { + guard + let args = arguments as? [String: Any], + let paths = args["paths"] as? [String] + else { + return false + } + return paths.allSatisfy { WhiteNoiseDataProtection.apply(atPath: $0) } + } + private func appGroupContainerPath() -> String? { guard let identifier = Bundle.main.object(forInfoDictionaryKey: "WNAppGroupIdentifier") as? String, @@ -259,3 +271,59 @@ private func wn_install_ios_background_keyring_store_with_access_group( } } } + +/// Applies `completeUntilFirstUserAuthentication` data protection to a directory +/// and everything inside it. +/// +/// The White Noise database lives in the shared App Group container so the +/// notification service extension can read it. Files there default to a +/// protection class that is unavailable while the device is locked; holding a +/// SQLite/WAL lock on such a file in a shared container across suspension makes +/// iOS terminate the process with 0xdead10cc. Setting the relaxed class keeps +/// the files reachable after first unlock and avoids that termination. +enum WhiteNoiseDataProtection { + private static let markerName = ".wn_data_protection_v1" + + @discardableResult + static func apply(atPath path: String) -> Bool { + let fileManager = FileManager.default + let attributes: [FileAttributeKey: Any] = [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ] + // Always set the class on the directory itself; new files (db, -wal, -shm, + // media cache) inherit it, so this is the only step needed on most launches. + var success = setAttributes(attributes, atPath: path, using: fileManager) + + // Downgrade files that predate this fix exactly once. Recursing the whole + // data dir (which holds the media cache) on every launch would be unbounded + // work, so a marker gates it after the first successful pass. + let markerPath = (path as NSString).appendingPathComponent(markerName) + guard !fileManager.fileExists(atPath: markerPath) else { + return success + } + if let enumerator = fileManager.enumerator(atPath: path) { + for case let relativePath as String in enumerator { + let itemPath = (path as NSString).appendingPathComponent(relativePath) + success = setAttributes(attributes, atPath: itemPath, using: fileManager) && success + } + } + if success { + fileManager.createFile(atPath: markerPath, contents: nil, attributes: attributes) + } + return success + } + + private static func setAttributes( + _ attributes: [FileAttributeKey: Any], + atPath path: String, + using fileManager: FileManager + ) -> Bool { + do { + try fileManager.setAttributes(attributes, ofItemAtPath: path) + return true + } catch { + NSLog("White Noise failed to set data protection on %@: %@", path, error.localizedDescription) + return false + } + } +} diff --git a/ios/WhiteNoiseNotificationServiceExtension/NotificationService.swift b/ios/WhiteNoiseNotificationServiceExtension/NotificationService.swift index f711f824..48284e19 100644 --- a/ios/WhiteNoiseNotificationServiceExtension/NotificationService.swift +++ b/ios/WhiteNoiseNotificationServiceExtension/NotificationService.swift @@ -179,6 +179,8 @@ final class NotificationService: UNNotificationServiceExtension { let logsDir = logsDirURL.path try? FileManager.default.createDirectory(at: dataDirURL, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: logsDirURL, withIntermediateDirectories: true) + WhiteNoiseDataProtection.apply(atPath: dataDir) + WhiteNoiseDataProtection.apply(atPath: logsDir) guard let json = collectNotificationsJson( dataDir: dataDir, @@ -269,6 +271,62 @@ final class NotificationService: UNNotificationServiceExtension { } } +/// Applies `completeUntilFirstUserAuthentication` data protection to a directory +/// and everything inside it. +/// +/// The White Noise database lives in the shared App Group container that this +/// extension reads. Files there default to a protection class that is +/// unavailable while the device is locked; holding a SQLite/WAL lock on such a +/// file in a shared container across suspension makes iOS terminate the process +/// with 0xdead10cc. Setting the relaxed class keeps the files reachable after +/// first unlock and avoids that termination. +enum WhiteNoiseDataProtection { + private static let markerName = ".wn_data_protection_v1" + + @discardableResult + static func apply(atPath path: String) -> Bool { + let fileManager = FileManager.default + let attributes: [FileAttributeKey: Any] = [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ] + // Always set the class on the directory itself; new files (db, -wal, -shm, + // media cache) inherit it, so this is the only step needed on most launches. + var success = setAttributes(attributes, atPath: path, using: fileManager) + + // Downgrade files that predate this fix exactly once. The extension runs on + // a tight time/memory budget, so recursing the whole data dir on every push + // is unacceptable; a marker gates it after the first successful pass. + let markerPath = (path as NSString).appendingPathComponent(markerName) + guard !fileManager.fileExists(atPath: markerPath) else { + return success + } + if let enumerator = fileManager.enumerator(atPath: path) { + for case let relativePath as String in enumerator { + let itemPath = (path as NSString).appendingPathComponent(relativePath) + success = setAttributes(attributes, atPath: itemPath, using: fileManager) && success + } + } + if success { + fileManager.createFile(atPath: markerPath, contents: nil, attributes: attributes) + } + return success + } + + private static func setAttributes( + _ attributes: [FileAttributeKey: Any], + atPath path: String, + using fileManager: FileManager + ) -> Bool { + do { + try fileManager.setAttributes(attributes, ofItemAtPath: path) + return true + } catch { + NSLog("White Noise NSE failed to set data protection on %@: %@", path, error.localizedDescription) + return false + } + } +} + private enum BackgroundPushCollectionResult { case success(BackgroundPushResult) case failure(String) diff --git a/lib/hooks/use_relay_resolution.dart b/lib/hooks/use_relay_resolution.dart index dbd50b6d..554af485 100644 --- a/lib/hooks/use_relay_resolution.dart +++ b/lib/hooks/use_relay_resolution.dart @@ -44,6 +44,7 @@ String _relayResolutionErrorMessage(Object error) { return switch (error) { ApiError_LoginNoRelayConnections() => 'loginErrorNoRelayConnections', ApiError_LoginTimeout() => 'loginErrorTimeout', + ApiError_LoginNoLoginInProgress() => 'loginErrorNoLoginInProgress', ApiError_LoginInternal() => 'loginErrorInternal', _ => 'loginErrorGeneric', }; diff --git a/lib/main.dart b/lib/main.dart index dd943f62..625b0ccd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -113,6 +113,8 @@ Future initializeAppContainer({ await _migrateDataIfNeeded(dataDir); + await applyIosDataProtection(paths: [dataDir, logsDir], isIOS: isIOS); + final config = await rust_api.createWhitenoiseConfig( dataDir: dataDir, logsDir: logsDir, @@ -129,6 +131,31 @@ Future initializeAppContainer({ Future _defaultInitializeRetryDelay(Duration delay) => Future.delayed(delay); +/// Relaxes the data-protection class of the White Noise data and log +/// directories on iOS so the encrypted SQLite database (and its WAL/SHM files) +/// stay accessible after first unlock. +/// +/// The database lives in the shared App Group container so the notification +/// service extension can read it. iOS files default to a protection class that +/// becomes unavailable while the device is locked. Holding a SQLite/WAL lock on +/// such a file in a shared container across suspension makes the OS terminate +/// the process with 0xdead10cc. Marking the directories +/// `completeUntilFirstUserAuthentication` keeps the files reachable and avoids +/// that kill. Best effort: failures are logged but never block startup. +@visibleForTesting +Future applyIosDataProtection({ + required List paths, + bool? isIOS, + MethodChannel appGroupChannel = _appGroupChannel, +}) async { + if (!(isIOS ?? Platform.isIOS)) return; + try { + await appGroupChannel.invokeMethod('applyDataProtection', {'paths': paths}); + } catch (error, stackTrace) { + _logger.warning('Failed to apply iOS data protection to White Noise data', error, stackTrace); + } +} + Future _hasPendingReset() async => (await resetPendingMarkerFile()).existsSync(); Future _deleteDocumentsDataAfterPendingReset(Directory activeBaseDir) async { diff --git a/lib/screens/relay_resolution_screen.dart b/lib/screens/relay_resolution_screen.dart index b2e8d1c8..f873c76a 100644 --- a/lib/screens/relay_resolution_screen.dart +++ b/lib/screens/relay_resolution_screen.dart @@ -20,6 +20,7 @@ String _resolveError(String errorKey, AppLocalizations l10n) { 'relayResolutionNotFound' => l10n.relayResolutionNotFound, 'loginErrorNoRelayConnections' => l10n.loginErrorNoRelayConnections, 'loginErrorTimeout' => l10n.loginErrorTimeout, + 'loginErrorNoLoginInProgress' => l10n.loginErrorNoLoginInProgress, 'loginErrorInternal' => l10n.loginErrorInternal, 'invalidRelayUrlScheme' => l10n.invalidRelayUrlScheme, 'invalidRelayUrl' => l10n.invalidRelayUrl, diff --git a/test/hooks/use_relay_resolution_test.dart b/test/hooks/use_relay_resolution_test.dart index 1f77cdfa..5008ec8b 100644 --- a/test/hooks/use_relay_resolution_test.dart +++ b/test/hooks/use_relay_resolution_test.dart @@ -1090,6 +1090,40 @@ void main() { expect(capturedState.error, 'loginErrorInternal'); }); + testWidgets('publishDefaults maps LoginNoLoginInProgress to specific key', (tester) async { + late Future Function() capturedPublishDefaults; + late RelayResolutionState capturedState; + + final widget = _buildTestWidget( + publishDefaultRelays: (_) async { + throw const ApiError.loginNoLoginInProgress(); + }, + onBuild: + ( + controller, + state, + isRelayUrlValid, + validationError, + trailingIcon, + trailingKey, + handleTrailingAction, + publishDefaults, + tryCustomRelay, + cancel, + clearError, + ) { + capturedPublishDefaults = publishDefaults; + capturedState = state; + }, + ); + await mountWidget(widget, tester); + + await capturedPublishDefaults(); + await tester.pump(); + + expect(capturedState.error, 'loginErrorNoLoginInProgress'); + }); + testWidgets('tryCustomRelay maps LoginTimeout to specific key', (tester) async { late Future Function() capturedTryCustomRelay; late RelayResolutionState capturedState; diff --git a/test/main_test.dart b/test/main_test.dart index f6ac44e2..d2212072 100644 --- a/test/main_test.dart +++ b/test/main_test.dart @@ -9,6 +9,7 @@ import 'package:logging/logging.dart'; import 'package:whitenoise/main.dart' show WnApp, + applyIosDataProtection, formatAppLogRecord, initializeAppContainer, initializeWhitenoiseWithRetry, @@ -931,4 +932,70 @@ void main() { ); }); }); + + group('applyIosDataProtection', () { + const channel = MethodChannel('org.parres.whitenoise/app_group'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + + test('invokes applyDataProtection with the given paths on iOS', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + calls.add(call); + return null; + }, + ); + + await applyIosDataProtection( + paths: const ['/tmp/data', '/tmp/logs'], + isIOS: true, + ); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'applyDataProtection'); + expect( + (calls.single.arguments as Map)['paths'], + ['/tmp/data', '/tmp/logs'], + ); + }); + + test('is a no-op on non-iOS platforms', () async { + var invoked = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + invoked = true; + return null; + }, + ); + + await applyIosDataProtection( + paths: const ['/tmp/data', '/tmp/logs'], + isIOS: false, + ); + + expect(invoked, isFalse); + }); + + test('swallows platform exceptions so startup is never blocked', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + throw PlatformException(code: 'unavailable'); + }, + ); + + await expectLater( + applyIosDataProtection(paths: const ['/tmp/data'], isIOS: true), + completes, + ); + }); + }); } diff --git a/test/screens/relay_resolution_screen_test.dart b/test/screens/relay_resolution_screen_test.dart index eef84d8e..22489262 100644 --- a/test/screens/relay_resolution_screen_test.dart +++ b/test/screens/relay_resolution_screen_test.dart @@ -450,6 +450,18 @@ void main() { ); }); + testWidgets('shows start-over message when no login is in progress', (tester) async { + await pumpRelayResolutionScreen(tester); + mockAuth.publishDefaultRelaysError = const ApiError.loginNoLoginInProgress(); + await tester.tap(find.byKey(const Key('use_default_relays_button'))); + await tester.pumpAndSettle(); + expect(find.byType(WnSystemNotice), findsOneWidget); + expect( + find.text('No login in progress. Please start over.'), + findsOneWidget, + ); + }); + testWidgets('shows connection error when custom relay has no connections', (tester) async { await pumpRelayResolutionScreen(tester); mockAuth.customRelayError = const ApiError.loginNoRelayConnections();