From a6db8e16f4ee65e44498e6e2d56ef9c82314af34 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 15 Jun 2026 14:00:04 +0200 Subject: [PATCH 1/2] fix(ios): relax App Group data protection to stop 0xdead10cc crash The encrypted SQLite database lives in the shared App Group container so the notification service extension can read it. Its files defaulted to NSFileProtectionComplete, which becomes 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 ("deadlock") via RUNNINGBOARD, surfaced to the user as "White Noise Crashed" during account creation/login. Apply NSFileProtectionCompleteUntilFirstUserAuthentication to the data and logs directories (and, once, to files that predate this fix) in both the app and the NSE before the database is opened. New files inherit the directory's class, so the recursive downgrade is gated by a marker to avoid walking the media cache on every launch / push. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Runner/AppDelegate.swift | 68 +++++++++++++++++++ .../NotificationService.swift | 58 ++++++++++++++++ lib/main.dart | 27 ++++++++ test/main_test.dart | 67 ++++++++++++++++++ 4 files changed, 220 insertions(+) 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/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/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, + ); + }); + }); } From 2ee9e10206f8065bdc63646cb66639848d9f842e Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 15 Jun 2026 14:00:16 +0200 Subject: [PATCH 2/2] fix(login): show actionable error when pending login is lost on relay screen The Relay Setup screen mapped only LoginNoRelayConnections/Timeout/Internal, so LoginNoLoginInProgress fell through to the generic "An error occurred during login. Please try again." The pending login is in-memory in the core, so once it is gone (e.g. after the app was killed) retrying on that screen can never succeed -- the user must restart from key entry, which the message did not say. Map LoginNoLoginInProgress on the relay screen, reusing the existing l10n string already shown on the login screen: "No login in progress. Please start over." Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/hooks/use_relay_resolution.dart | 1 + lib/screens/relay_resolution_screen.dart | 1 + test/hooks/use_relay_resolution_test.dart | 34 +++++++++++++++++++ .../screens/relay_resolution_screen_test.dart | 12 +++++++ 4 files changed, 48 insertions(+) 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/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/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();