Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -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);
}