Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Comment on lines +284 to +314

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider writing the marker file even on partial success.

If setAttributes fails for some files (e.g., a locked file) but succeeds for others, the current logic skips creating the marker. On the next launch the code re-enumerates and re-attempts the entire directory. This is resilient for transient failures but means every launch incurs the full recursive walk until 100% success.

Given that the PR describes this as a one-time migration for files that "predate this fix" and the NSE operates under tight time/memory constraints, consider creating the marker unconditionally (or on first invocation regardless of outcome) so the recursive pass is truly bounded to once. Persistent failures would still be logged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/Runner/AppDelegate.swift` around lines 284 - 314, In the apply(atPath:)
method of the WhiteNoiseDataProtection enum, the marker file is currently
created only when all setAttributes calls succeed. To ensure the recursive
directory walk is bounded to a single launch and not repeated on subsequent
launches when transient failures occur, modify the logic so that the marker file
is created after the enumeration loop regardless of the success variable's final
value, or create it unconditionally before returning. This way, even if some
files fail to have their attributes set, the migration attempt is marked as
completed and the expensive recursive walk will not be repeated on the next
launch.


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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
}
Comment on lines +274 to +328

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Duplicated WhiteNoiseDataProtection across targets.

This enum is copied verbatim from AppDelegate.swift. While iOS app extension architecture often necessitates such duplication (the NSE and main app are separate targets), consider extracting this into a shared Swift module or framework to avoid drift and ease future maintenance.

If extraction is not feasible, add a comment in both files noting the duplication so future changes are synchronized.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/WhiteNoiseNotificationServiceExtension/NotificationService.swift` around
lines 274 - 328, The WhiteNoiseDataProtection enum is duplicated verbatim
between this NotificationService.swift file and AppDelegate.swift in the main
app target. To fix this, either extract the enum into a shared Swift module or
framework that both the main app and notification service extension can depend
on, OR if that is not feasible, add a clear comment in both the current location
and in AppDelegate.swift that notes this duplication exists and that any future
changes to one must be synchronized with the other. The goal is to prevent the
two implementations from drifting out of sync over time.


private enum BackgroundPushCollectionResult {
case success(BackgroundPushResult)
case failure(String)
Expand Down
1 change: 1 addition & 0 deletions lib/hooks/use_relay_resolution.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ String _relayResolutionErrorMessage(Object error) {
return switch (error) {
ApiError_LoginNoRelayConnections() => 'loginErrorNoRelayConnections',
ApiError_LoginTimeout() => 'loginErrorTimeout',
ApiError_LoginNoLoginInProgress() => 'loginErrorNoLoginInProgress',
ApiError_LoginInternal() => 'loginErrorInternal',
_ => 'loginErrorGeneric',
};
Expand Down
27 changes: 27 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ Future<ProviderContainer> initializeAppContainer({

await _migrateDataIfNeeded(dataDir);

await applyIosDataProtection(paths: [dataDir, logsDir], isIOS: isIOS);

final config = await rust_api.createWhitenoiseConfig(
dataDir: dataDir,
logsDir: logsDir,
Expand All @@ -129,6 +131,31 @@ Future<ProviderContainer> initializeAppContainer({

Future<void> _defaultInitializeRetryDelay(Duration delay) => Future<void>.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<void> applyIosDataProtection({
required List<String> paths,
bool? isIOS,
MethodChannel appGroupChannel = _appGroupChannel,
}) async {
if (!(isIOS ?? Platform.isIOS)) return;
try {
await appGroupChannel.invokeMethod<void>('applyDataProtection', {'paths': paths});
} catch (error, stackTrace) {
_logger.warning('Failed to apply iOS data protection to White Noise data', error, stackTrace);
}
}

Future<bool> _hasPendingReset() async => (await resetPendingMarkerFile()).existsSync();

Future<void> _deleteDocumentsDataAfterPendingReset(Directory activeBaseDir) async {
Expand Down
1 change: 1 addition & 0 deletions lib/screens/relay_resolution_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions test/hooks/use_relay_resolution_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,40 @@ void main() {
expect(capturedState.error, 'loginErrorInternal');
});

testWidgets('publishDefaults maps LoginNoLoginInProgress to specific key', (tester) async {
late Future<bool> 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<bool> Function() capturedTryCustomRelay;
late RelayResolutionState capturedState;
Expand Down
67 changes: 67 additions & 0 deletions test/main_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:logging/logging.dart';
import 'package:whitenoise/main.dart'
show
WnApp,
applyIosDataProtection,
formatAppLogRecord,
initializeAppContainer,
initializeWhitenoiseWithRetry,
Expand Down Expand Up @@ -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 = <MethodCall>[];
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,
);
});
});
}
12 changes: 12 additions & 0 deletions test/screens/relay_resolution_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down