Add Supa account and search changes
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:supabase/supabase.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/providers/device_info.dart';
|
||||
import 'package:weblibre/features/about/domain/providers.dart';
|
||||
import 'package:weblibre/features/account/data/account_secure_store.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_persisted_data.dart';
|
||||
import 'package:weblibre/features/account/data/models/persisted_session.dart';
|
||||
import 'package:weblibre/features/account/data/supabase_config.dart';
|
||||
import 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/pkce.dart';
|
||||
|
||||
// Re-export so call sites that already imported AccountAuthFlowException from
|
||||
// this repository keep compiling after the redeem client split.
|
||||
export 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart'
|
||||
show AccountAuthFlowException;
|
||||
|
||||
part 'account_auth.g.dart';
|
||||
|
||||
/// Convert any thrown error into a message safe to show in the UI.
|
||||
/// Untrusted exception strings (e.g. `e.toString()` for arbitrary HTTP /
|
||||
/// platform errors) can include response bodies, headers, or auth tokens —
|
||||
/// log them in full but never put them in user-visible state.
|
||||
String _sanitizeAuthError(Object error, String fallback) {
|
||||
if (error is AccountAuthFlowException) {
|
||||
return error.userMessage;
|
||||
}
|
||||
if (error is AuthRetryableFetchException) {
|
||||
return 'Network error. Please check your connection and try again.';
|
||||
}
|
||||
if (error is AuthException) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AccountAuthRepository extends _$AccountAuthRepository {
|
||||
StreamSubscription<AuthState>? _authSubscription;
|
||||
Timer? _signingInTimeout;
|
||||
Timer? _restoreRetryTimer;
|
||||
|
||||
AccountSecureStore get _store => ref.read(accountSecureStoreProvider);
|
||||
HandoffRedeemClient get _redeemClient =>
|
||||
ref.read(handoffRedeemClientProvider);
|
||||
|
||||
AccountAuthState get _currentOrEmpty => state.value ?? AccountAuthState();
|
||||
|
||||
@override
|
||||
Future<AccountAuthState> build() async {
|
||||
ref.onDispose(() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_restoreRetryTimer?.cancel();
|
||||
await _authSubscription?.cancel();
|
||||
final client = state.value?.client;
|
||||
await client?.dispose();
|
||||
});
|
||||
|
||||
_restoreRetryTimer?.cancel();
|
||||
_restoreRetryTimer = null;
|
||||
|
||||
final data = await _store.read();
|
||||
|
||||
if (data.session == null) {
|
||||
return AccountAuthState();
|
||||
}
|
||||
|
||||
try {
|
||||
final client = _createClient();
|
||||
final response = await client.auth.setSession(data.session!.refreshToken);
|
||||
|
||||
if (response.session != null) {
|
||||
_listenToAuthState(client);
|
||||
final user = response.session!.user;
|
||||
|
||||
await _persistSession(response.session!, data);
|
||||
|
||||
return AccountAuthState(
|
||||
status: AccountAuthStatus.signedIn,
|
||||
email: user.email,
|
||||
displayName:
|
||||
user.userMetadata?['display_name'] as String? ??
|
||||
user.userMetadata?['full_name'] as String? ??
|
||||
user.email,
|
||||
userId: user.id,
|
||||
syncKey: data.syncKey,
|
||||
client: client,
|
||||
);
|
||||
} else {
|
||||
await client.dispose();
|
||||
return AccountAuthState();
|
||||
}
|
||||
} on AuthRetryableFetchException catch (e) {
|
||||
// Transient network error — preserve session and retry shortly.
|
||||
return _transientRestoreFailure(data, e);
|
||||
} on AuthException {
|
||||
// Definitive auth failure (expired/revoked token) — clear credentials.
|
||||
await _store.clear();
|
||||
return AccountAuthState();
|
||||
} catch (e) {
|
||||
// Non-auth error (e.g. SocketException) — also transient, preserve.
|
||||
return _transientRestoreFailure(data, e);
|
||||
}
|
||||
}
|
||||
|
||||
AccountAuthState _transientRestoreFailure(
|
||||
AccountPersistedData data,
|
||||
Object error,
|
||||
) {
|
||||
_scheduleRestoreRetry();
|
||||
return AccountAuthState(
|
||||
status: AccountAuthStatus.error,
|
||||
email: data.email,
|
||||
displayName: data.displayName ?? data.email,
|
||||
userId: data.userId,
|
||||
syncKey: data.syncKey,
|
||||
lastError: _sanitizeAuthError(
|
||||
error,
|
||||
'Could not restore your account session. Retrying shortly.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleRestoreRetry() {
|
||||
_restoreRetryTimer?.cancel();
|
||||
_restoreRetryTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (ref.mounted) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Auth state listener ---------------------------------------------------
|
||||
|
||||
SupabaseClient _createClient() {
|
||||
return SupabaseClient(
|
||||
SupabaseConfig.supabaseUrl,
|
||||
SupabaseConfig.supabaseAnonKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _listenToAuthState(SupabaseClient client) {
|
||||
unawaited(_authSubscription?.cancel());
|
||||
_authSubscription = client.auth.onAuthStateChange.listen((data) {
|
||||
if (data.event == AuthChangeEvent.signedOut ||
|
||||
// ignore: deprecated_member_use
|
||||
data.event == AuthChangeEvent.userDeleted) {
|
||||
unawaited(_handleSignedOut());
|
||||
} else if (data.event == AuthChangeEvent.tokenRefreshed &&
|
||||
data.session != null) {
|
||||
unawaited(_persistSessionRefresh(data.session!));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSignedOut() async {
|
||||
await _store.clear();
|
||||
// Stashed Privacy Pass tokens survive sign-out: they are anonymous
|
||||
// blobs already redeemed against the user's credit balance, and the
|
||||
// backend cannot link them back to the issuing account. Clearing them
|
||||
// here would destroy prepaid value with no refund path.
|
||||
await _authSubscription?.cancel();
|
||||
final client = state.value?.client;
|
||||
await client?.dispose();
|
||||
state = AsyncData(AccountAuthState());
|
||||
}
|
||||
|
||||
// -- Sign-in flow ----------------------------------------------------------
|
||||
|
||||
Future<void> startSignIn() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn),
|
||||
);
|
||||
|
||||
try {
|
||||
final codes = PkceCodes.generate();
|
||||
|
||||
final data = await _store.read();
|
||||
await _store.write(data.copyWith(pendingCodeVerifier: codes.verifier));
|
||||
|
||||
final queryParams = <String, String>{
|
||||
'mode': 'handoff',
|
||||
'code_challenge': codes.challenge,
|
||||
};
|
||||
|
||||
final packageInfoData = ref.read(packageInfoProvider).value;
|
||||
if (packageInfoData != null) {
|
||||
queryParams['app_version'] =
|
||||
'${packageInfoData.version}+${packageInfoData.buildNumber}';
|
||||
}
|
||||
|
||||
final deviceInfoData = ref.read(androidDeviceInfoProvider).value;
|
||||
if (deviceInfoData != null) {
|
||||
queryParams['device_name'] = deviceInfoData.deviceName;
|
||||
}
|
||||
|
||||
final baseUri = Uri.parse(SupabaseConfig.accountWebUrl);
|
||||
final uri = baseUri.replace(queryParameters: queryParams);
|
||||
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
if (!launched) {
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: 'Could not open sign-in page',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the timer last so any earlier error path doesn't have to think
|
||||
// about cancelling a timer it never started. Guard the body so that a
|
||||
// timer fired after handleHandoffCode has reset _signingInTimeout to
|
||||
// null does nothing.
|
||||
late final Timer timer;
|
||||
timer = Timer(const Duration(minutes: 5), () {
|
||||
if (!identical(_signingInTimeout, timer)) return;
|
||||
_signingInTimeout = null;
|
||||
if (state.value?.status == AccountAuthStatus.signingIn) {
|
||||
state = AsyncData(
|
||||
AccountAuthState(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: 'Sign-in timed out. Please try again.',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
_signingInTimeout = timer;
|
||||
} catch (e, s) {
|
||||
logger.e('startSignIn failed', error: e, stackTrace: s);
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: _sanitizeAuthError(
|
||||
e,
|
||||
'Could not open the sign-in page. Please try again.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelSignIn() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
|
||||
// Clear the pending code verifier so a late browser callback is rejected.
|
||||
final data = await _store.read();
|
||||
// ignore: avoid_redundant_argument_values
|
||||
await _store.write(data.copyWith(pendingCodeVerifier: null));
|
||||
|
||||
state = AsyncData(AccountAuthState());
|
||||
}
|
||||
|
||||
Future<void> handleHandoffCode(String code) async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn),
|
||||
);
|
||||
|
||||
try {
|
||||
final data = await _store.read();
|
||||
final codeVerifier = data.pendingCodeVerifier;
|
||||
|
||||
if (codeVerifier == null) {
|
||||
throw AccountAuthFlowException(
|
||||
'No pending sign-in found. Please start sign-in again.',
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _redeemClient.redeem(
|
||||
handoffCode: code,
|
||||
codeVerifier: codeVerifier,
|
||||
);
|
||||
|
||||
final refreshToken = result.session['refresh_token'] as String;
|
||||
|
||||
final previousClient = _currentOrEmpty.client;
|
||||
final newClient = _createClient();
|
||||
|
||||
try {
|
||||
await newClient.auth.setSession(refreshToken);
|
||||
} catch (e) {
|
||||
await newClient.dispose();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
await previousClient?.dispose();
|
||||
_listenToAuthState(newClient);
|
||||
|
||||
// Persist session and clear the verifier in one write. Reuse the
|
||||
// existing persisted record via copyWith so any unrelated fields
|
||||
// (notably syncKey) survive a re-sign-in without being clobbered.
|
||||
final persistedSession = PersistedSession.fromJson(result.session);
|
||||
final user = result.session['user'] as Map<String, dynamic>?;
|
||||
await _store.write(
|
||||
data.copyWith(
|
||||
session: persistedSession,
|
||||
userId: user?['id'] as String?,
|
||||
email: user?['email'] as String?,
|
||||
displayName:
|
||||
(user?['user_metadata'] as Map<String, dynamic>?)?['display_name']
|
||||
as String?,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
pendingCodeVerifier: null,
|
||||
),
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
AccountAuthState(
|
||||
status: AccountAuthStatus.signedIn,
|
||||
email: result.account['email'] as String?,
|
||||
displayName: result.account['display_name'] as String?,
|
||||
userId: result.account['user_id'] as String?,
|
||||
syncKey: _currentOrEmpty.syncKey,
|
||||
client: newClient,
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
logger.e('handleHandoffCode failed', error: e, stackTrace: s);
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: _sanitizeAuthError(e, 'Sign-in failed. Please try again.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await state.value?.client?.auth.signOut();
|
||||
} catch (_) {
|
||||
// Sign out may fail if the session is already invalid
|
||||
}
|
||||
await _handleSignedOut();
|
||||
}
|
||||
|
||||
// -- Sync key management ---------------------------------------------------
|
||||
|
||||
Future<void> setSyncKey(String key) async {
|
||||
final data = await _store.read();
|
||||
await _store.write(data.copyWith(syncKey: key));
|
||||
state = AsyncData(_currentOrEmpty.copyWith(syncKey: key));
|
||||
}
|
||||
|
||||
Future<void> clearSyncKey() async {
|
||||
final data = await _store.read();
|
||||
// ignore: avoid_redundant_argument_values
|
||||
await _store.write(data.copyWith(syncKey: null));
|
||||
// ignore: avoid_redundant_argument_values
|
||||
state = AsyncData(_currentOrEmpty.copyWith(syncKey: null));
|
||||
}
|
||||
|
||||
// -- Session persistence helpers ------------------------------------------
|
||||
|
||||
Future<void> _persistSession(
|
||||
Session session,
|
||||
AccountPersistedData current,
|
||||
) async {
|
||||
await _store.write(
|
||||
current.copyWith(
|
||||
session: PersistedSession(
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken!,
|
||||
tokenType: session.tokenType,
|
||||
expiresIn: session.expiresIn ?? 3600,
|
||||
),
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
displayName:
|
||||
session.user.userMetadata?['display_name'] as String? ??
|
||||
session.user.userMetadata?['full_name'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistSessionRefresh(Session session) async {
|
||||
final data = await _store.read();
|
||||
await _persistSession(session, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AccountAuthRepository)
|
||||
final accountAuthRepositoryProvider = AccountAuthRepositoryProvider._();
|
||||
|
||||
final class AccountAuthRepositoryProvider
|
||||
extends $AsyncNotifierProvider<AccountAuthRepository, AccountAuthState> {
|
||||
AccountAuthRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountAuthRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountAuthRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AccountAuthRepository create() => AccountAuthRepository();
|
||||
}
|
||||
|
||||
String _$accountAuthRepositoryHash() =>
|
||||
r'604bd3954347d0ed6cd4f894bff8388642787104';
|
||||
|
||||
abstract class _$AccountAuthRepository
|
||||
extends $AsyncNotifier<AccountAuthState> {
|
||||
FutureOr<AccountAuthState> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<AccountAuthState>, AccountAuthState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<AccountAuthState>, AccountAuthState>,
|
||||
AsyncValue<AccountAuthState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/models/subscription_status.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
|
||||
part 'subscription_repository.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SubscriptionRepository extends _$SubscriptionRepository {
|
||||
@override
|
||||
Future<SubscriptionStatus> build() async {
|
||||
final authState = ref.watch(accountAuthRepositoryProvider).value;
|
||||
if (authState == null || !authState.isSignedIn) {
|
||||
// Signed-out is not an error; it's a known "no subscription" state.
|
||||
return SubscriptionStatus.inactive;
|
||||
}
|
||||
|
||||
// Don't swallow RPC failures as `SubscriptionStatus.inactive` — that
|
||||
// renders identically to "user has no subscription" and would prompt
|
||||
// them to subscribe when the actual fix is to retry. Let errors
|
||||
// propagate so the AsyncValue carries them.
|
||||
final client = authState.client!;
|
||||
final response = await client.rpc('get_my_subscription_status').single();
|
||||
return SubscriptionStatus.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
ref.invalidateSelf();
|
||||
// Fire-and-forget; consumers re-render from the new AsyncValue.
|
||||
try {
|
||||
await future;
|
||||
} catch (_) {
|
||||
// intentionally ignored — see comment above
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'subscription_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SubscriptionRepository)
|
||||
final subscriptionRepositoryProvider = SubscriptionRepositoryProvider._();
|
||||
|
||||
final class SubscriptionRepositoryProvider
|
||||
extends $AsyncNotifierProvider<SubscriptionRepository, SubscriptionStatus> {
|
||||
SubscriptionRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'subscriptionRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$subscriptionRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SubscriptionRepository create() => SubscriptionRepository();
|
||||
}
|
||||
|
||||
String _$subscriptionRepositoryHash() =>
|
||||
r'c618435ad9e22316809a0a7f573dcea28301ba46';
|
||||
|
||||
abstract class _$SubscriptionRepository
|
||||
extends $AsyncNotifier<SubscriptionStatus> {
|
||||
FutureOr<SubscriptionStatus> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<SubscriptionStatus>, SubscriptionStatus>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<SubscriptionStatus>, SubscriptionStatus>,
|
||||
AsyncValue<SubscriptionStatus>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/services/sharing_intent.dart';
|
||||
|
||||
part 'account_callback_handler.g.dart';
|
||||
|
||||
/// Parsed `weblibre://account/callback?code=...` deep link.
|
||||
class AccountCallback {
|
||||
final String handoffCode;
|
||||
|
||||
const AccountCallback({required this.handoffCode});
|
||||
}
|
||||
|
||||
/// Parses [data] as a WebLibre account callback URI. Returns `null` if it
|
||||
/// isn't one (any other intent) or if the `code` query parameter is
|
||||
/// missing/empty. Callers can check `!= null` instead of running two
|
||||
/// passes (used-to-be `isAccountCallbackUri` then `extractHandoffCode`).
|
||||
AccountCallback? tryParseAccountCallback(String data) {
|
||||
final uri = Uri.tryParse(data);
|
||||
if (uri == null) return null;
|
||||
if (uri.scheme != 'weblibre' ||
|
||||
uri.host != 'account' ||
|
||||
uri.path != '/callback') {
|
||||
return null;
|
||||
}
|
||||
final code = uri.queryParameters['code'];
|
||||
if (code == null || code.isEmpty) return null;
|
||||
return AccountCallback(handoffCode: code);
|
||||
}
|
||||
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
@Riverpod(keepAlive: true)
|
||||
void accountCallbackHandler(Ref ref) {
|
||||
final stream = ref.watch(accountCallbackStreamProvider);
|
||||
|
||||
final subscription = stream.listen((code) async {
|
||||
logger.i('Received account handoff callback');
|
||||
await ref
|
||||
.read(accountAuthRepositoryProvider.notifier)
|
||||
.handleHandoffCode(code);
|
||||
});
|
||||
|
||||
ref.onDispose(subscription.cancel);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_callback_handler.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
|
||||
@ProviderFor(accountCallbackHandler)
|
||||
final accountCallbackHandlerProvider = AccountCallbackHandlerProvider._();
|
||||
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
|
||||
final class AccountCallbackHandlerProvider
|
||||
extends $FunctionalProvider<void, void, void>
|
||||
with $Provider<void> {
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
AccountCallbackHandlerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountCallbackHandlerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountCallbackHandlerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<void> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
void create(Ref ref) {
|
||||
return accountCallbackHandler(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountCallbackHandlerHash() =>
|
||||
r'8d8e627efed8c030a2a9fe179cc71bdc33dd79c0';
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/supabase_config.dart';
|
||||
|
||||
part 'handoff_redeem_client.g.dart';
|
||||
|
||||
/// Thrown by [HandoffRedeemClient.redeem] when we want to surface a
|
||||
/// specific, already-safe message to the user (e.g. a server-supplied error
|
||||
/// string). Distinguishes "messages we trust to show verbatim" from
|
||||
/// arbitrary `Exception.toString()` output, which may carry HTTP bodies,
|
||||
/// tokens, or stack frames.
|
||||
class AccountAuthFlowException implements Exception {
|
||||
final String userMessage;
|
||||
AccountAuthFlowException(this.userMessage);
|
||||
|
||||
@override
|
||||
String toString() => userMessage;
|
||||
}
|
||||
|
||||
/// Successful response from the `handoff-redeem` Supabase function. Holds
|
||||
/// the raw `session` and `account` payloads so the caller decides how to
|
||||
/// persist them.
|
||||
class HandoffRedeemResult {
|
||||
final Map<String, dynamic> session;
|
||||
final Map<String, dynamic> account;
|
||||
|
||||
const HandoffRedeemResult({required this.session, required this.account});
|
||||
}
|
||||
|
||||
/// Stateless client for the account web app's `handoff-redeem` endpoint.
|
||||
/// Owns the HTTP transport and the response parsing so the auth repository
|
||||
/// only orchestrates state transitions around it.
|
||||
class HandoffRedeemClient {
|
||||
final http.Client _client;
|
||||
|
||||
HandoffRedeemClient({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
/// Exchange a one-time `handoff_code` plus the matching PKCE
|
||||
/// `code_verifier` for a Supabase session. Throws
|
||||
/// [AccountAuthFlowException] on a non-200 status with the server's error
|
||||
/// message when available, or a generic fallback otherwise.
|
||||
Future<HandoffRedeemResult> redeem({
|
||||
required String handoffCode,
|
||||
required String codeVerifier,
|
||||
}) async {
|
||||
const redeemUrl =
|
||||
'${SupabaseConfig.supabaseUrl}/functions/v1/handoff-redeem';
|
||||
final response = await _client.post(
|
||||
Uri.parse(redeemUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': SupabaseConfig.supabaseAnonKey,
|
||||
},
|
||||
body: jsonEncode({
|
||||
'handoff_code': handoffCode,
|
||||
'code_verifier': codeVerifier,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
// Try to extract the server's error field. The full response body
|
||||
// is logged by the caller — we only surface the trusted message
|
||||
// field to the UI to avoid leaking response detail.
|
||||
String? serverMessage;
|
||||
try {
|
||||
final error = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
serverMessage = error['error'] as String?;
|
||||
} catch (_) {
|
||||
// Body wasn't JSON — fall back to a generic message so we don't
|
||||
// echo HTML/HTTP detail to the user.
|
||||
}
|
||||
throw AccountAuthFlowException(
|
||||
serverMessage ?? 'Sign-in failed. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
final responseData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return HandoffRedeemResult(
|
||||
session: responseData['session'] as Map<String, dynamic>,
|
||||
account: responseData['account'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
HandoffRedeemClient handoffRedeemClient(Ref ref) {
|
||||
final client = HandoffRedeemClient();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'handoff_redeem_client.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(handoffRedeemClient)
|
||||
final handoffRedeemClientProvider = HandoffRedeemClientProvider._();
|
||||
|
||||
final class HandoffRedeemClientProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
HandoffRedeemClient,
|
||||
HandoffRedeemClient,
|
||||
HandoffRedeemClient
|
||||
>
|
||||
with $Provider<HandoffRedeemClient> {
|
||||
HandoffRedeemClientProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'handoffRedeemClientProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$handoffRedeemClientHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<HandoffRedeemClient> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
HandoffRedeemClient create(Ref ref) {
|
||||
return handoffRedeemClient(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(HandoffRedeemClient value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<HandoffRedeemClient>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$handoffRedeemClientHash() =>
|
||||
r'6746d14547090c9ff4fcdc1c554fc7aa3d1e6795';
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/user_js_parser.dart';
|
||||
import 'package:weblibre/utils/filesystem.dart';
|
||||
|
||||
/// Reads persisted Gecko user prefs directly from the active profile's
|
||||
/// `prefs.js` file.
|
||||
///
|
||||
/// Gecko writes `user_pref(...)` entries only for prefs with a user-set value,
|
||||
/// so reading this file gives us the syncable modified-prefs view without
|
||||
/// needing a native bridge.
|
||||
class PrefsJsReader {
|
||||
final Directory selectedProfileDir;
|
||||
|
||||
PrefsJsReader({required this.selectedProfileDir});
|
||||
|
||||
/// Returns a map of user-set prefs parsed from `prefs.js`, or an empty map
|
||||
/// if no `prefs.js` file exists for the active profile.
|
||||
Future<Map<String, Object>> readUserPrefs() async {
|
||||
final file = _resolvePrefsJsFile();
|
||||
if (file == null || !await file.exists()) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final text = await file.readAsString();
|
||||
return parseUserJs(text).prefs;
|
||||
}
|
||||
|
||||
File? _resolvePrefsJsFile() {
|
||||
final profileIds = getMozillaProfileIds(selectedProfileDir);
|
||||
if (profileIds.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final candidates = profileIds
|
||||
.map(
|
||||
(id) => File(
|
||||
p.join(selectedProfileDir.path, 'files', 'mozilla', id, 'prefs.js'),
|
||||
),
|
||||
)
|
||||
.where((f) => f.existsSync())
|
||||
.toList();
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidates.length == 1) {
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
logger.w(
|
||||
'Multiple Gecko profiles with prefs.js found (${candidates.length}); '
|
||||
'selecting newest by modification time',
|
||||
);
|
||||
|
||||
candidates.sort(
|
||||
(a, b) => b.statSync().modified.compareTo(a.statSync().modified),
|
||||
);
|
||||
return candidates.first;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/services/prefs_js_reader.dart';
|
||||
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/user_js_parser.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/user_js_serializer.dart';
|
||||
|
||||
part 'prefs_sync_service.g.dart';
|
||||
|
||||
const _schemaVersion = 1;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class PrefsSyncService extends _$PrefsSyncService
|
||||
implements SyncDocumentService {
|
||||
final GeckoPrefService _prefService = GeckoPrefService();
|
||||
final PrefsJsReader _prefsReader = PrefsJsReader(
|
||||
selectedProfileDir: filesystem.selectedProfileDir,
|
||||
);
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
@override
|
||||
SyncDocumentKind get kind => SyncDocumentKind.geckoUserJs;
|
||||
|
||||
@override
|
||||
int get schemaVersion => _schemaVersion;
|
||||
|
||||
@override
|
||||
Future<List<int>> serializeCurrent() async {
|
||||
final userPrefs = await _prefsReader.readUserPrefs();
|
||||
|
||||
final text = serializeUserJs(
|
||||
userPrefs: userPrefs,
|
||||
schemaVersion: _schemaVersion,
|
||||
);
|
||||
|
||||
return utf8.encode(text);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyRestored(List<int> plaintext) async {
|
||||
final text = utf8.decode(plaintext);
|
||||
final parsed = parseUserJs(text);
|
||||
|
||||
if (parsed.schemaVersion != null &&
|
||||
parsed.schemaVersion! > _schemaVersion) {
|
||||
throw Exception(
|
||||
'Unsupported prefs schema version: ${parsed.schemaVersion} '
|
||||
'(this app supports up to $_schemaVersion)',
|
||||
);
|
||||
}
|
||||
|
||||
final remotePrefs = parsed.prefs;
|
||||
|
||||
// Find local user-set prefs whose exported form would have been preserved,
|
||||
// so we can reset those absent from the remote snapshot.
|
||||
final localPrefs = await _prefsReader.readUserPrefs();
|
||||
final syncableLocalKeys = localPrefs.entries
|
||||
.where((e) => isSyncablePrefValue(e.value))
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
|
||||
final prefsToReset = syncableLocalKeys
|
||||
.difference(remotePrefs.keys.toSet())
|
||||
.toList();
|
||||
|
||||
if (prefsToReset.isNotEmpty) {
|
||||
await _prefService.resetPrefs(prefsToReset);
|
||||
}
|
||||
|
||||
if (remotePrefs.isNotEmpty) {
|
||||
await _prefService.applyPrefs(remotePrefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'prefs_sync_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(PrefsSyncService)
|
||||
final prefsSyncServiceProvider = PrefsSyncServiceProvider._();
|
||||
|
||||
final class PrefsSyncServiceProvider
|
||||
extends $NotifierProvider<PrefsSyncService, void> {
|
||||
PrefsSyncServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'prefsSyncServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$prefsSyncServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PrefsSyncService create() => PrefsSyncService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$prefsSyncServiceHash() => r'9e95a066d751c12dc2a46bbe041c49e0389a7874';
|
||||
|
||||
abstract class _$PrefsSyncService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/models/settings_sync_envelope.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
||||
|
||||
part 'settings_sync_service.g.dart';
|
||||
|
||||
const _schemaVersion = 1;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SettingsSyncService extends _$SettingsSyncService
|
||||
implements SyncDocumentService {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
@override
|
||||
SyncDocumentKind get kind => SyncDocumentKind.weblibreSettings;
|
||||
|
||||
@override
|
||||
int get schemaVersion => _schemaVersion;
|
||||
|
||||
@override
|
||||
Future<List<int>> serializeCurrent() async {
|
||||
final (general, engine, tor) = await (
|
||||
ref.read(generalSettingsRepositoryProvider.notifier).fetchSettings(),
|
||||
ref.read(engineSettingsRepositoryProvider.notifier).fetchSettings(),
|
||||
ref.read(torSettingsRepositoryProvider.notifier).fetchSettings(),
|
||||
).wait;
|
||||
|
||||
final envelope = SettingsSyncEnvelope(
|
||||
schemaVersion: _schemaVersion,
|
||||
exportedAt: DateTime.now().toUtc().toIso8601String(),
|
||||
payload: SettingsSyncPayload(general: general, engine: engine, tor: tor),
|
||||
);
|
||||
|
||||
return utf8.encode(jsonEncode(envelope.toJson()));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyRestored(List<int> plaintext) async {
|
||||
final json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
||||
final envelope = SettingsSyncEnvelope.fromJson(json);
|
||||
|
||||
if (envelope.schemaVersion > _schemaVersion) {
|
||||
throw Exception(
|
||||
'Unsupported settings schema version: ${envelope.schemaVersion} '
|
||||
'(this app supports up to $_schemaVersion)',
|
||||
);
|
||||
}
|
||||
|
||||
final payload = envelope.payload;
|
||||
|
||||
if (payload.general != null) {
|
||||
await ref
|
||||
.read(generalSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((_) => payload.general!);
|
||||
}
|
||||
if (payload.engine != null) {
|
||||
await ref
|
||||
.read(engineSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((_) => payload.engine!);
|
||||
}
|
||||
if (payload.tor != null) {
|
||||
await ref
|
||||
.read(torSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((_) => payload.tor!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings_sync_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SettingsSyncService)
|
||||
final settingsSyncServiceProvider = SettingsSyncServiceProvider._();
|
||||
|
||||
final class SettingsSyncServiceProvider
|
||||
extends $NotifierProvider<SettingsSyncService, void> {
|
||||
SettingsSyncServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'settingsSyncServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$settingsSyncServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SettingsSyncService create() => SettingsSyncService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$settingsSyncServiceHash() =>
|
||||
r'3f7488d705394417b3d8ca6319c978d65f1ef35d';
|
||||
|
||||
abstract class _$SettingsSyncService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
|
||||
/// Contract for document kinds that can be synced.
|
||||
///
|
||||
/// Implementations handle serialization and deserialization of their domain
|
||||
/// data. Encryption, repository interaction, and UI are handled externally
|
||||
/// by the reusable [SyncDocumentListSection] widget.
|
||||
abstract class SyncDocumentService {
|
||||
/// The document kind identifier for Supabase storage.
|
||||
SyncDocumentKind get kind;
|
||||
|
||||
/// Current schema version for this document kind.
|
||||
int get schemaVersion;
|
||||
|
||||
/// Serializes the current app state to plaintext bytes for encryption.
|
||||
Future<List<int>> serializeCurrent();
|
||||
|
||||
/// Deserializes decrypted plaintext bytes and applies them to app state.
|
||||
Future<void> applyRestored(List<int> plaintext);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A PKCE code-verifier / code-challenge pair tied to a single sign-in
|
||||
/// attempt. The verifier stays on the device; the challenge is sent to the
|
||||
/// account web app and later redeemed alongside the verifier to prove the
|
||||
/// app instance that started the flow is the one finishing it.
|
||||
///
|
||||
/// **Encoding deviates from RFC 7636.** The reference spec mandates
|
||||
/// base64url for both verifier and `S256` challenge; this implementation
|
||||
/// uses lowercase hex on both sides:
|
||||
/// - verifier: 32 random bytes encoded as 64 hex characters (within the
|
||||
/// 43–128 range allowed by RFC 7636 §4.1).
|
||||
/// - challenge: hex digest of `SHA-256(UTF-8(verifier))`.
|
||||
///
|
||||
/// Both sides of the flow are owned (this client and the `handoff-redeem`
|
||||
/// Supabase function), so the wire format is internally consistent. The
|
||||
/// deviation matters only if the redeem endpoint is ever replaced with a
|
||||
/// standards-compliant OAuth server — in which case both `_generateCodeVerifier`
|
||||
/// and `_challengeFor` must switch to base64url to interoperate.
|
||||
class PkceCodes {
|
||||
final String verifier;
|
||||
final String challenge;
|
||||
|
||||
const PkceCodes({required this.verifier, required this.challenge});
|
||||
|
||||
factory PkceCodes.generate() {
|
||||
final verifier = _generateCodeVerifier();
|
||||
return PkceCodes(verifier: verifier, challenge: _challengeFor(verifier));
|
||||
}
|
||||
|
||||
static String _generateCodeVerifier() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
static String _challengeFor(String verifier) {
|
||||
return sha256.convert(utf8.encode(verifier)).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
/// Parses Firefox-compatible `user.js` text into a map of pref name to value.
|
||||
///
|
||||
/// Accepts the Firefox `user.js` subset used by WebLibre:
|
||||
/// - blank lines and whitespace
|
||||
/// - `//`, `#`, and `/* ... */` comments
|
||||
/// - `user_pref("name", value);`
|
||||
/// - string, boolean, and integer values
|
||||
///
|
||||
/// Duplicate pref names use last-write-wins semantics.
|
||||
class UserJsParseResult {
|
||||
final Map<String, Object> prefs;
|
||||
final int? schemaVersion;
|
||||
final String? exportedAt;
|
||||
|
||||
UserJsParseResult({required this.prefs, this.schemaVersion, this.exportedAt});
|
||||
}
|
||||
|
||||
UserJsParseResult parseUserJs(String text) {
|
||||
final parser = _UserJsParser(text);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
class _UserJsParser {
|
||||
final String _text;
|
||||
int _index = 0;
|
||||
final Map<String, Object> _prefs = <String, Object>{};
|
||||
int? _schemaVersion;
|
||||
String? _exportedAt;
|
||||
|
||||
_UserJsParser(this._text);
|
||||
|
||||
UserJsParseResult parse() {
|
||||
while (true) {
|
||||
_skipTrivia();
|
||||
if (_isEof) {
|
||||
break;
|
||||
}
|
||||
|
||||
final identifier = _parseIdentifier();
|
||||
if (identifier != 'user_pref') {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar('(')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = _parseStringLiteral();
|
||||
if (name == null) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar(',')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
final value = _parseValue();
|
||||
if (value == null) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar(')')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
_skipTrivia();
|
||||
if (!_consumeRawChar(';')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
_prefs[name] = value;
|
||||
}
|
||||
|
||||
return UserJsParseResult(
|
||||
prefs: _prefs,
|
||||
schemaVersion: _schemaVersion,
|
||||
exportedAt: _exportedAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _isEof => _index >= _text.length;
|
||||
|
||||
String? _peek([int offset = 0]) {
|
||||
final position = _index + offset;
|
||||
if (position >= _text.length) {
|
||||
return null;
|
||||
}
|
||||
return _text[position];
|
||||
}
|
||||
|
||||
String? _advance() {
|
||||
if (_isEof) {
|
||||
return null;
|
||||
}
|
||||
return _text[_index++];
|
||||
}
|
||||
|
||||
bool _consumeRawChar(String char) {
|
||||
if (_peek() != char) {
|
||||
return false;
|
||||
}
|
||||
_index++;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _consumeChar(String char) {
|
||||
_skipTrivia();
|
||||
return _consumeRawChar(char);
|
||||
}
|
||||
|
||||
void _skipTrivia() {
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isWhitespace(char)) {
|
||||
_advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '#') {
|
||||
_skipLineComment(isSlashComment: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '/' && _peek(1) == '/') {
|
||||
_skipLineComment(isSlashComment: true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '/' && _peek(1) == '*') {
|
||||
_skipBlockComment();
|
||||
continue;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipLineComment({required bool isSlashComment}) {
|
||||
final prefixLength = isSlashComment ? 2 : 1;
|
||||
final start = _index + prefixLength;
|
||||
_index += prefixLength;
|
||||
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == '\n' || char == '\r') {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
|
||||
if (isSlashComment) {
|
||||
_parseMetadataComment(_text.substring(start, _index).trim());
|
||||
}
|
||||
|
||||
if (_peek() == '\r') {
|
||||
_index++;
|
||||
if (_peek() == '\n') {
|
||||
_index++;
|
||||
}
|
||||
} else if (_peek() == '\n') {
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipBlockComment() {
|
||||
_index += 2;
|
||||
while (!_isEof) {
|
||||
if (_peek() == '*' && _peek(1) == '/') {
|
||||
_index += 2;
|
||||
return;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
void _parseMetadataComment(String comment) {
|
||||
if (comment.startsWith('schema_version=')) {
|
||||
_schemaVersion = int.tryParse(
|
||||
comment.substring('schema_version='.length),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (comment.startsWith('exported_at=')) {
|
||||
_exportedAt = comment.substring('exported_at='.length);
|
||||
}
|
||||
}
|
||||
|
||||
String? _parseIdentifier() {
|
||||
_skipTrivia();
|
||||
final start = _index;
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null || !_isIdentifierChar(char)) {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
if (_index == start) {
|
||||
return null;
|
||||
}
|
||||
return _text.substring(start, _index);
|
||||
}
|
||||
|
||||
Object? _parseValue() {
|
||||
_skipTrivia();
|
||||
final char = _peek();
|
||||
if (char == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char == '"' || char == "'") {
|
||||
return _parseStringLiteral();
|
||||
}
|
||||
|
||||
if (char == 't' || char == 'f') {
|
||||
final identifier = _parseIdentifier();
|
||||
if (identifier == 'true') {
|
||||
return true;
|
||||
}
|
||||
if (identifier == 'false') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return _parseIntLiteral();
|
||||
}
|
||||
|
||||
int? _parseIntLiteral() {
|
||||
_skipTrivia();
|
||||
final start = _index;
|
||||
|
||||
var sign = 1;
|
||||
if (_peek() == '+') {
|
||||
_index++;
|
||||
} else if (_peek() == '-') {
|
||||
sign = -1;
|
||||
_index++;
|
||||
}
|
||||
|
||||
// Firefox tokenizes +/- separately, then skips whitespace/comments before
|
||||
// reading the integer literal.
|
||||
_skipTrivia();
|
||||
|
||||
final digitStart = _index;
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null || !_isDigit(char)) {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
|
||||
if (_index == digitStart) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final trailing = _peek();
|
||||
if (trailing != null && _isIdentifierChar(trailing)) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final digits = _text.substring(digitStart, _index);
|
||||
final value = int.tryParse(digits);
|
||||
if (value == null) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final signedValue = sign * value;
|
||||
if (signedValue < -2147483648 || signedValue > 2147483647) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
return signedValue;
|
||||
}
|
||||
|
||||
String? _parseStringLiteral() {
|
||||
_skipTrivia();
|
||||
final quote = _peek();
|
||||
if (quote != '"' && quote != "'") {
|
||||
return null;
|
||||
}
|
||||
|
||||
_index++;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
while (!_isEof) {
|
||||
final char = _advance();
|
||||
if (char == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char == quote) {
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
if (char != '\\') {
|
||||
buffer.write(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
final escaped = _parseEscape();
|
||||
if (escaped == null) {
|
||||
return null;
|
||||
}
|
||||
buffer.write(escaped);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _parseEscape() {
|
||||
final char = _advance();
|
||||
switch (char) {
|
||||
case '"':
|
||||
return '"';
|
||||
case "'":
|
||||
return "'";
|
||||
case '\\':
|
||||
return '\\';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 'x':
|
||||
final value = _parseHexValue(length: 2);
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return String.fromCharCode(value);
|
||||
case 'u':
|
||||
final value = _parseUnicodeEscape();
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return String.fromCharCode(value);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseUnicodeEscape() {
|
||||
final value = _parseHexValue(length: 4);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_isHighSurrogate(value)) {
|
||||
if (_advance() != '\\' || _advance() != 'u') {
|
||||
return null;
|
||||
}
|
||||
final lowValue = _parseHexValue(length: 4);
|
||||
if (lowValue == null || !_isLowSurrogate(lowValue)) {
|
||||
return null;
|
||||
}
|
||||
return 0x10000 + ((value - 0xD800) << 10) + (lowValue - 0xDC00);
|
||||
}
|
||||
|
||||
if (_isLowSurrogate(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
int? _parseHexValue({required int length}) {
|
||||
var value = 0;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final char = _advance();
|
||||
final digit = char == null ? null : _hexDigitValue(char);
|
||||
if (digit == null) {
|
||||
return null;
|
||||
}
|
||||
value = (value << 4) + digit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int? _hexDigitValue(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
if (code >= 0x30 && code <= 0x39) {
|
||||
return code - 0x30;
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x46) {
|
||||
return code - 0x41 + 10;
|
||||
}
|
||||
if (code >= 0x61 && code <= 0x66) {
|
||||
return code - 0x61 + 10;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _skipToStatementEnd() {
|
||||
while (!_isEof) {
|
||||
final char = _advance();
|
||||
if (char == ';') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isWhitespace(String char) {
|
||||
return char == ' ' ||
|
||||
char == '\t' ||
|
||||
char == '\n' ||
|
||||
char == '\r' ||
|
||||
char == '\v' ||
|
||||
char == '\f';
|
||||
}
|
||||
|
||||
bool _isIdentifierChar(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
return (code >= 0x41 && code <= 0x5A) ||
|
||||
(code >= 0x61 && code <= 0x7A) ||
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
code == 0x5F;
|
||||
}
|
||||
|
||||
bool _isDigit(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
return code >= 0x30 && code <= 0x39;
|
||||
}
|
||||
|
||||
bool _isHighSurrogate(int value) => value >= 0xD800 && value <= 0xDBFF;
|
||||
|
||||
bool _isLowSurrogate(int value) => value >= 0xDC00 && value <= 0xDFFF;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
/// Unsyncable URL-style pref value prefixes (inspired by Firefox Sync).
|
||||
const unsyncablePrefPrefixes = ['moz-extension:', 'blob:', 'data:', 'file:'];
|
||||
|
||||
/// Returns true if a persisted Gecko pref value is syncable (supported scalar
|
||||
/// type and not an unsyncable URL-style string value).
|
||||
bool isSyncablePrefValue(Object value) {
|
||||
if (value is bool || value is int) return true;
|
||||
if (value is String) {
|
||||
return !unsyncablePrefPrefixes.any(value.startsWith);
|
||||
}
|
||||
// Unsupported type (e.g. double, list)
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Serializes persisted Gecko user prefs into canonical `user.js` text.
|
||||
///
|
||||
/// [userPrefs] is the map of user-set prefs parsed from the active Gecko
|
||||
/// profile's `prefs.js` file.
|
||||
///
|
||||
/// String-valued prefs starting with unsyncable URL prefixes are excluded.
|
||||
String serializeUserJs({
|
||||
required Map<String, Object> userPrefs,
|
||||
required int schemaVersion,
|
||||
String? exportedAt,
|
||||
}) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('// WebLibre Gecko prefs snapshot');
|
||||
buffer.writeln('// schema_version=$schemaVersion');
|
||||
buffer.writeln(
|
||||
'// exported_at=${exportedAt ?? DateTime.now().toUtc().toIso8601String()}',
|
||||
);
|
||||
|
||||
final syncable =
|
||||
userPrefs.entries.where((e) => isSyncablePrefValue(e.value)).toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
for (final entry in syncable) {
|
||||
final literal = _toLiteral(entry.value);
|
||||
if (literal == null) continue;
|
||||
final escapedName = _escapeString(entry.key);
|
||||
buffer.writeln('user_pref("$escapedName", $literal);');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String? _toLiteral(Object? value) {
|
||||
if (value is bool) return value.toString();
|
||||
if (value is int) return value.toString();
|
||||
if (value is String) return '"${_escapeString(value)}"';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _escapeString(String value) {
|
||||
return value
|
||||
.replaceAll('\\', r'\\')
|
||||
.replaceAll('"', r'\"')
|
||||
.replaceAll('\n', r'\n')
|
||||
.replaceAll('\r', r'\r');
|
||||
}
|
||||
Reference in New Issue
Block a user