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,690 @@
import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
as fmc;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:search_backend/search_backend.dart';
import 'package:search_client/search_client.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
show CaptureTabData;
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart';
import 'package:weblibre/features/search_credits/domain/providers.dart';
import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart';
import 'package:weblibre/features/user/domain/repositories/cache.dart';
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
import 'package:weblibre/features/web_search/domain/services/capture_server.dart';
import 'package:weblibre/features/web_search/domain/services/sandbox_capture_store.dart';
part 'sandbox_capture_controller.g.dart';
enum SandboxCaptureErrorKind {
/// Stash is empty AND user has zero credits — needs to buy more.
insufficientCredits,
/// Stash is empty, credits are available, but issuance failed
/// (network/auth/server). Retrying may succeed.
tokenIssuanceFailed,
fetchPolicyRejected,
captureFailed,
downloadFailed,
unknown,
}
class SandboxCaptureError {
final SandboxCaptureErrorKind kind;
final Uri targetUrl;
final String? detail;
const SandboxCaptureError({
required this.kind,
required this.targetUrl,
this.detail,
});
}
@Riverpod(keepAlive: true)
OneShotCaptureClient oneShotCaptureClient(Ref ref) {
return OneShotCaptureClient(
endpoints: ref.watch(searchBackendEndpointsProvider),
logger: ref.watch(searchClientLoggerProvider),
httpClient: ref.watch(searchProxyHttpClientProvider),
);
}
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
/// the UI can show the canonical source URL (instead of the loopback
/// loader/capture URL) and a sandbox indicator.
@Riverpod(keepAlive: true)
Stream<Map<String, CaptureTabData>> sandboxCaptureMap(Ref ref) {
final dao = ref.watch(tabDatabaseProvider).captureTabDao;
return dao.watchAll().map((rows) => {for (final row in rows) row.tabId: row});
}
@Riverpod()
CaptureTabData? sandboxCaptureForTab(Ref ref, {required String? tabId}) {
if (tabId == null) return null;
final map = ref.watch(sandboxCaptureMapProvider).value;
return map?[tabId];
}
/// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case).
@Riverpod()
Uri? sandboxSourceUriForTab(Ref ref, {required String? tabId}) {
final row = ref.watch(sandboxCaptureForTabProvider(tabId: tabId));
return parseSandboxSource(row);
}
Uri? parseSandboxSource(CaptureTabData? row) {
if (row == null) return null;
final raw = row.sourceUrl;
if (raw.isEmpty) return null;
return Uri.tryParse(raw);
}
/// Search/edit text to pre-fill when the user taps the address bar of
/// [tabState]. Sandbox-captured tabs surface their source URL so editing
/// doesn't strip the user back to the loopback loader.
String searchTextForTab(TabState tabState, [Uri? sandboxSourceUri]) {
if (sandboxSourceUri != null) {
return sandboxSourceUri.toString();
}
final searchText = tabState.url.scheme == 'about'
? ''
: tabState.url.toString();
return searchText.isEmpty ? SearchRoute.emptySearchText : searchText;
}
@Riverpod(keepAlive: true)
SandboxCaptureStore sandboxCaptureStore(Ref ref) => SandboxCaptureStore();
@Riverpod(keepAlive: true)
Stream<SandboxCaptureError> sandboxCaptureErrors(Ref ref) {
final ctrl = ref.watch(sandboxCaptureControllerProvider.notifier);
return ctrl.errors;
}
/// Orchestrates sandbox capture browsing:
///
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
/// `onSandboxNewTab`) and runs the capture pipeline.
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
/// in sync with the `capture_tab` table via a DAO subscription.
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
///
/// This provider is kept alive for the lifetime of the app; `build()` wires
/// up the subscriptions and returns nothing interesting.
@Riverpod(keepAlive: true)
class SandboxCaptureController extends _$SandboxCaptureController {
final _errors = StreamController<SandboxCaptureError>.broadcast();
// Keyed by (tabId, url) so two concurrent navigations in the same tab to
// different URLs don't dedupe each other, and the same URL across tabs
// doesn't either. Records compare structurally so this is safer than the
// earlier "tabId|url" string concat.
final _inFlight = <(String, Uri)>{};
final _hostEvents = _SandboxHostEventsHandler();
/// In-memory record of the (method, variant) used to capture each sandbox
/// tab. Children opened via link-click inherit their parent's mode so a
/// PDF sandbox tab spawns more PDFs, a singlefile tab spawns more
/// singlefile, etc. Lost across app restarts (capture_tab schema doesn't
/// store these yet) — children fall back to singlefile/balanced in that
/// case.
final _tabCaptureMode = <String, ({String method, String variant})>{};
static const _defaultMethod = 'singlefile';
static const _defaultVariant = 'balanced';
StreamSubscription<List<CaptureTabData>>? _captureTabSub;
StreamSubscription<RetryRequest>? _retrySub;
Stream<SandboxCaptureError> get errors => _errors.stream;
@override
void build() {
_hostEvents.controller = this;
fmc.SandboxCaptureHostEvents.setUp(_hostEvents);
final dao = ref.read(tabDatabaseProvider).captureTabDao;
_captureTabSub = dao.watchAll().listen(_onCaptureTabChange);
_retrySub = ref
.read(captureServerProvider)
.retryRequests
.listen(_onRetryRequest);
ref.onDispose(() {
// Cancel inbound subscriptions first so no more events can land on
// this controller. Only after that is it safe to detach the host
// event handler, drop the back-reference, and close the error
// stream — otherwise an in-flight Pigeon event or DAO emit could
// race with handler cleanup, or _markFailed could try to add to a
// closed _errors sink.
final captureTabSub = _captureTabSub;
final retrySub = _retrySub;
if (captureTabSub != null) unawaited(captureTabSub.cancel());
if (retrySub != null) unawaited(retrySub.cancel());
fmc.SandboxCaptureHostEvents.setUp(null);
_hostEvents.controller = null;
unawaited(_errors.close());
});
}
// Entry point: search-results flow opened a root capture tab.
Future<void> registerRootCapture({
required String tabId,
required CapturedPageReceiptLike receipt,
String? method,
String? variant,
}) async {
final server = ref.read(captureServerProvider);
await server.publish(receipt.captureId);
final dao = ref.read(tabDatabaseProvider).captureTabDao;
await dao.upsert(
tabId: tabId,
captureId: receipt.captureId,
sourceUrl: receipt.sourceUrl.toString(),
status: CaptureTabStatus.ready,
);
_tabCaptureMode[tabId] = (
method: method ?? _defaultMethod,
variant: variant ?? _defaultVariant,
);
await _pushRegistry();
}
// Called from Kotlin via pigeon when a sandbox tab tried to navigate to a
// non-loopback URL. We open a fresh private tab and run the capture
// pipeline into it.
Future<void> captureIntoNewTab({
required String parentTabId,
required Uri targetUrl,
}) async {
if (_inFlight.contains((parentTabId, targetUrl))) return;
final parent = ref.read(tabStatesProvider)[parentTabId];
final parentTabMode =
parent?.tabMode ??
await ref
.read(tabDatabaseProvider)
.tabDao
.getTabMode(parentTabId)
.getSingleOrNull() ??
TabMode.private;
final parentContainer = await ref
.read(tabDatabaseProvider)
.tabDao
.getTabContainerData(parentTabId)
.getSingleOrNull();
final containerSelection = parentContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(parentContainer);
// Create the tab without an initial URL — we'll load the loopback
// loader URL in _runCapture. Never seed the tab with the real
// targetUrl: Gecko's app-link resolution would fire on load/restore
// (e.g. github.com → "Open in GitHub app?" dialog) before the
// interceptor can redirect.
final newTabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(
tabMode: parentTabMode,
selectTab: true,
parentId: parentTabId,
containerSelection: containerSelection,
startLoading: false,
);
await _runCapture(
parentTabId: parentTabId,
newTabId: newTabId,
targetUrl: targetUrl,
triggerInitialLoad: true,
);
}
// Called from Kotlin middleware when GeckoView created a new tab inside a
// sandbox parent. The new tab is already at about:blank.
Future<void> captureIntoExistingTab({
required String parentTabId,
required String newTabId,
required Uri targetUrl,
}) async {
await _runCapture(
parentTabId: parentTabId,
newTabId: newTabId,
targetUrl: targetUrl,
triggerInitialLoad: true,
);
}
/// Resolve the (method, variant) for a child capture. Children inherit
/// the parent tab's capture mode so a PDF sandbox spawns more PDFs, etc.
/// Falls back to singlefile/balanced when the parent's mode is unknown
/// (e.g. after an app restart — `_tabCaptureMode` is in-memory only).
({String method, String variant}) _resolveCaptureMode(String parentTabId) {
return _tabCaptureMode[parentTabId] ??
(method: _defaultMethod, variant: _defaultVariant);
}
Future<void> _runCapture({
required String parentTabId,
required String newTabId,
required Uri targetUrl,
required bool triggerInitialLoad,
String? existingCaptureId,
}) async {
final key = (newTabId, targetUrl);
if (!_inFlight.add(key)) return;
final server = ref.read(captureServerProvider);
final dao = ref.read(tabDatabaseProvider).captureTabDao;
final logger = ref.read(searchClientLoggerProvider);
final mode = _resolveCaptureMode(parentTabId);
// For retries, reuse the existing placeholder id so the tab's loader
// URL stays valid and its in-flight long-poll picks up the new state.
final placeholderId =
existingCaptureId ??
'pending-${DateTime.now().microsecondsSinceEpoch}-$newTabId';
final stash = ref.read(searchTokenStashProvider);
ReservedStashToken? heldReservation;
try {
server.clearFailed(placeholderId);
await dao.upsert(
tabId: newTabId,
captureId: placeholderId,
sourceUrl: targetUrl.toString(),
status: CaptureTabStatus.pending,
);
_tabCaptureMode[newTabId] = mode;
await _pushRegistry();
if (triggerInitialLoad) {
final loaderUri = await server.loaderUrl(
tabId: newTabId,
captureId: placeholderId,
);
await ref
.read(tabSessionProvider(tabId: newTabId).notifier)
.loadUrl(url: loaderUri);
}
var reserved = await stash.reserveOne();
if (reserved == null) {
// Stash empty — try to top up with the same 10-token batch we use
// on the search submit path, then retry the reservation once.
logger.i(
'Sandbox capture: stash empty, attempting auto-issuance '
'(tabId=$newTabId, host=${targetUrl.host})',
);
final outcome = await ref.read(searchTokenAvailabilityProvider).topUp();
if (outcome == TokenTopUpOutcome.issued) {
reserved = await stash.reserveOne();
}
if (reserved == null) {
final kind = outcome == TokenTopUpOutcome.noCredits
? SandboxCaptureErrorKind.insufficientCredits
: SandboxCaptureErrorKind.tokenIssuanceFailed;
final detail = outcome == TokenTopUpOutcome.noCredits
? 'You have no search credits left. Purchase more to continue.'
: 'Could not issue new search tokens. Check your connection and try again.';
logger.w(
'Sandbox capture: auto-issuance did not yield a token '
'(outcome=$outcome, host=${targetUrl.host})',
);
await _markFailed(
tabId: newTabId,
captureId: placeholderId,
url: targetUrl,
kind: kind,
detail: detail,
);
return;
}
logger.i(
'Sandbox capture: auto-issuance succeeded, retrying capture '
'(tabId=$newTabId)',
);
}
heldReservation = reserved;
final reservation = reserved;
logger.d(
'Sandbox capture: reserved token #${reservation.id}, requesting '
'capture (tabId=$newTabId, host=${targetUrl.host}, '
'method=${mode.method}, variant=${mode.variant})',
);
final CaptureArtifactReceipt receipt;
try {
// Inherit the parent tab's capture mode: a PDF sandbox tab spawns
// more PDFs, a singlefile tab spawns more singlefile, etc. The
// capture server happily serves PDF/PNG artifacts on the loopback
// origin, and the loader/redirect machinery is content-type
// agnostic.
final result = await ref
.read(oneShotCaptureClientProvider)
.capture(
targetUrl,
method: mode.method,
variant: mode.variant,
token: reservation.token,
dimensions: currentDisplayCaptureDimensions(),
);
receipt = result.receipt;
// Server accepted and consumed the token — drop it permanently.
await stash.commitReserved(reservation.id);
heldReservation = null;
if (result.faviconBytes != null) {
unawaited(
ref
.read(cacheRepositoryProvider.notifier)
.cacheIconIfAbsent(receipt.url, result.faviconBytes!),
);
}
} on OneShotCaptureException catch (e) {
// Token-affecting failure modes:
// - fetch_not_allowed / invalid_url : server rejected before
// redeem → safe to release.
// - token_invalid / token_redeemed : server saw the token but
// did not honor it → must commit (do not double-spend).
// - any other 4xx/5xx after redemption attempt: ambiguous → commit.
final preRedemption =
e.code == 'fetch_not_allowed' ||
e.code == 'invalid_url' ||
e.code == 'invalid_request';
if (preRedemption) {
await stash.releaseReserved(reservation.id);
logger.i(
'Sandbox capture: released token #${reservation.id} '
'(server rejected request before redemption: ${e.code})',
);
} else {
await stash.commitReserved(reservation.id);
logger.w(
'Sandbox capture: committed token #${reservation.id} after '
'ambiguous server failure (${e.code}) to avoid double-spend',
);
}
heldReservation = null;
final kind = e.code == 'fetch_not_allowed'
? SandboxCaptureErrorKind.fetchPolicyRejected
: SandboxCaptureErrorKind.captureFailed;
await _markFailed(
tabId: newTabId,
captureId: placeholderId,
url: targetUrl,
kind: kind,
detail: e.message,
);
return;
} catch (e, s) {
// Network/transport failure — we don't know whether the server
// saw the token. Commit defensively to prevent double-spend on
// retry. Stale reservations also get cleaned up on app start.
await stash.commitReserved(reservation.id);
heldReservation = null;
logger.e(
'Sandbox capture failed (committed token #${reservation.id} '
'defensively)',
error: e,
stackTrace: s,
);
await _markFailed(
tabId: newTabId,
captureId: placeholderId,
url: targetUrl,
kind: SandboxCaptureErrorKind.unknown,
detail: e.toString(),
);
return;
}
try {
await ref.read(captureArtifactDownloaderProvider).download(receipt);
} catch (e, s) {
logger.e('Sandbox capture download failed', error: e, stackTrace: s);
await _markFailed(
tabId: newTabId,
captureId: placeholderId,
url: targetUrl,
kind: SandboxCaptureErrorKind.downloadFailed,
detail: e.toString(),
);
return;
}
final captureUri = await server.publish(receipt.captureId);
await dao.upsert(
tabId: newTabId,
captureId: receipt.captureId,
sourceUrl: targetUrl.toString(),
status: CaptureTabStatus.ready,
);
_tabCaptureMode[newTabId] = mode;
await _pushRegistry();
// Navigate directly to the loopback capture URL. Never bounce
// through the real targetUrl — Gecko's app-link resolution fires
// before the interceptor can redirect.
await ref
.read(tabSessionProvider(tabId: newTabId).notifier)
.loadUrl(url: captureUri);
} catch (e, s) {
// Safety net: anything that escaped the inner blocks (e.g. DAO
// failure, navigation error, exception while we held a reservation
// outside the inner try) — make sure we don't strand the tab in
// pending and don't leak the reservation. Commit defensively if a
// reservation is still held: we may have already sent the token to
// the server.
logger.e('Sandbox capture: unexpected error', error: e, stackTrace: s);
if (heldReservation != null) {
try {
await stash.commitReserved(heldReservation.id);
} catch (commitError, commitStack) {
logger.w(
'Sandbox capture: failed to commit reservation '
'#${heldReservation.id} during error cleanup',
error: commitError,
stackTrace: commitStack,
);
}
}
try {
await _markFailed(
tabId: newTabId,
captureId: placeholderId,
url: targetUrl,
kind: SandboxCaptureErrorKind.unknown,
detail: e.toString(),
);
} catch (markError, markStack) {
logger.w(
'Sandbox capture: failed to mark tab as failed during error '
'cleanup',
error: markError,
stackTrace: markStack,
);
}
} finally {
_inFlight.remove(key);
}
}
Future<void> _markFailed({
required String tabId,
required String captureId,
required Uri url,
required SandboxCaptureErrorKind kind,
required String detail,
}) async {
final dao = ref.read(tabDatabaseProvider).captureTabDao;
await dao.updateStatus(tabId, CaptureTabStatus.failed);
// Resolve any in-flight loader long-poll for this capture so the tab
// flips to the error UI immediately instead of waiting for the poll
// window to expire.
ref.read(captureServerProvider).markFailed(captureId);
await _pushRegistry();
if (!_errors.isClosed) {
_errors.add(
SandboxCaptureError(kind: kind, targetUrl: url, detail: detail),
);
}
}
Future<void> _onRetryRequest(RetryRequest request) async {
final dao = ref.read(tabDatabaseProvider).captureTabDao;
final row = await dao.findByTabId(request.tabId);
if (row == null) return;
final sourceUrl = Uri.tryParse(row.sourceUrl);
if (sourceUrl == null) return;
// Re-enter the capture pipeline as if the user clicked the link again.
// Reuse the existing capture id so the tab's loader URL (and its
// in-flight long-poll) stays addressable.
await _runCapture(
parentTabId: row.tabId, // we don't persist parent; use self as parent
newTabId: row.tabId,
targetUrl: sourceUrl,
triggerInitialLoad: false,
existingCaptureId: row.captureId,
);
}
Future<void> _onCaptureTabChange(List<CaptureTabData> rows) async {
final store = ref.read(sandboxCaptureStoreProvider);
await store.write(await _rowsForPersistentMirror(rows));
// Drop in-memory mode entries for tabs the DAO no longer knows about so
// a recycled tabId doesn't accidentally inherit a stale mode.
final liveTabIds = rows.map((r) => r.tabId).toSet();
_tabCaptureMode.removeWhere((tabId, _) => !liveTabIds.contains(tabId));
await _pushRegistry(rows: rows);
}
Future<List<CaptureTabData>> _rowsForPersistentMirror(
List<CaptureTabData> rows,
) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
final tabStates = ref.read(tabStatesProvider);
final persistedRows = <CaptureTabData>[];
for (final row in rows) {
final tabMode =
tabStates[row.tabId]?.tabMode ??
await tabDao.getTabMode(row.tabId).getSingleOrNull();
if (tabMode is PrivateTabMode) {
continue;
}
persistedRows.add(row);
}
return persistedRows;
}
Future<void> _pushRegistry({List<CaptureTabData>? rows}) async {
final dao = ref.read(tabDatabaseProvider).captureTabDao;
final server = ref.read(captureServerProvider);
final all = rows ?? await dao.findAll();
// Resolve redirect URLs in parallel. Each `_redirectUrlFor` awaits a
// server publish/loaderUrl call; serializing was O(N) round-trips on
// every DAO emit, which got noticeable with many sandbox tabs open.
final redirectUrls = await Future.wait([
for (final row in all) _redirectUrlFor(row, server),
]);
final entries = <fmc.SandboxCaptureEntry>[
for (var i = 0; i < all.length; i++)
fmc.SandboxCaptureEntry(
tabId: all[i].tabId,
captureId: all[i].captureId,
sourceUrl: all[i].sourceUrl,
redirectUrl: redirectUrls[i],
status: all[i].status,
),
];
await fmc.SandboxCaptureApi().resetAll(entries);
}
Future<String> _redirectUrlFor(
CaptureTabData row,
CaptureServer server,
) async {
switch (row.status) {
case 'ready':
final uri = await server.publish(row.captureId);
return uri.toString();
case 'failed':
final uri = await server.loaderUrl(
tabId: row.tabId,
captureId: row.captureId,
error: true,
);
return uri.toString();
case 'pending':
default:
final uri = await server.loaderUrl(
tabId: row.tabId,
captureId: row.captureId,
);
return uri.toString();
}
}
}
class _SandboxHostEventsHandler implements fmc.SandboxCaptureHostEvents {
SandboxCaptureController? controller;
@override
void onSandboxLinkClick(int sequence, String parentTabId, String targetUrl) {
final uri = Uri.tryParse(targetUrl);
final c = controller;
if (uri == null || c == null) return;
unawaited(c.captureIntoNewTab(parentTabId: parentTabId, targetUrl: uri));
}
@override
void onSandboxNewTab(
int sequence,
String parentTabId,
String newTabId,
String targetUrl,
) {
final uri = Uri.tryParse(targetUrl);
final c = controller;
if (uri == null || c == null) return;
unawaited(
c.captureIntoExistingTab(
parentTabId: parentTabId,
newTabId: newTabId,
targetUrl: uri,
),
);
}
}
/// Minimal receipt-like type for registerRootCapture — avoids a direct
/// dependency on the capture_artifact_receipt pigeon in this file when the
/// caller already has the right pieces to hand.
class CapturedPageReceiptLike {
final String captureId;
final Uri sourceUrl;
const CapturedPageReceiptLike({
required this.captureId,
required this.sourceUrl,
});
}
@@ -0,0 +1,472 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sandbox_capture_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(oneShotCaptureClient)
final oneShotCaptureClientProvider = OneShotCaptureClientProvider._();
final class OneShotCaptureClientProvider
extends
$FunctionalProvider<
OneShotCaptureClient,
OneShotCaptureClient,
OneShotCaptureClient
>
with $Provider<OneShotCaptureClient> {
OneShotCaptureClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'oneShotCaptureClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$oneShotCaptureClientHash();
@$internal
@override
$ProviderElement<OneShotCaptureClient> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
OneShotCaptureClient create(Ref ref) {
return oneShotCaptureClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(OneShotCaptureClient value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<OneShotCaptureClient>(value),
);
}
}
String _$oneShotCaptureClientHash() =>
r'35c3dbeb91a5c9ca1663f43f4b48332bcde3ae3f';
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
/// the UI can show the canonical source URL (instead of the loopback
/// loader/capture URL) and a sandbox indicator.
@ProviderFor(sandboxCaptureMap)
final sandboxCaptureMapProvider = SandboxCaptureMapProvider._();
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
/// the UI can show the canonical source URL (instead of the loopback
/// loader/capture URL) and a sandbox indicator.
final class SandboxCaptureMapProvider
extends
$FunctionalProvider<
AsyncValue<Map<String, CaptureTabData>>,
Map<String, CaptureTabData>,
Stream<Map<String, CaptureTabData>>
>
with
$FutureModifier<Map<String, CaptureTabData>>,
$StreamProvider<Map<String, CaptureTabData>> {
/// Streams capture-tab rows keyed by tabId. Watched by the address bar so
/// the UI can show the canonical source URL (instead of the loopback
/// loader/capture URL) and a sandbox indicator.
SandboxCaptureMapProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sandboxCaptureMapProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxCaptureMapHash();
@$internal
@override
$StreamProviderElement<Map<String, CaptureTabData>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<String, CaptureTabData>> create(Ref ref) {
return sandboxCaptureMap(ref);
}
}
String _$sandboxCaptureMapHash() => r'1771bb1c618e34f24e718609d501c426ac49f192';
@ProviderFor(sandboxCaptureForTab)
final sandboxCaptureForTabProvider = SandboxCaptureForTabFamily._();
final class SandboxCaptureForTabProvider
extends
$FunctionalProvider<CaptureTabData?, CaptureTabData?, CaptureTabData?>
with $Provider<CaptureTabData?> {
SandboxCaptureForTabProvider._({
required SandboxCaptureForTabFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'sandboxCaptureForTabProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxCaptureForTabHash();
@override
String toString() {
return r'sandboxCaptureForTabProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<CaptureTabData?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
CaptureTabData? create(Ref ref) {
final argument = this.argument as String?;
return sandboxCaptureForTab(ref, tabId: argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CaptureTabData? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CaptureTabData?>(value),
);
}
@override
bool operator ==(Object other) {
return other is SandboxCaptureForTabProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$sandboxCaptureForTabHash() =>
r'1d64f68c85d40dbd7d5b220ee54a5436f1c1eaeb';
final class SandboxCaptureForTabFamily extends $Family
with $FunctionalFamilyOverride<CaptureTabData?, String?> {
SandboxCaptureForTabFamily._()
: super(
retry: null,
name: r'sandboxCaptureForTabProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SandboxCaptureForTabProvider call({required String? tabId}) =>
SandboxCaptureForTabProvider._(argument: tabId, from: this);
@override
String toString() => r'sandboxCaptureForTabProvider';
}
/// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case).
@ProviderFor(sandboxSourceUriForTab)
final sandboxSourceUriForTabProvider = SandboxSourceUriForTabFamily._();
/// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case).
final class SandboxSourceUriForTabProvider
extends $FunctionalProvider<Uri?, Uri?, Uri?>
with $Provider<Uri?> {
/// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case).
SandboxSourceUriForTabProvider._({
required SandboxSourceUriForTabFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'sandboxSourceUriForTabProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxSourceUriForTabHash();
@override
String toString() {
return r'sandboxSourceUriForTabProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<Uri?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Uri? create(Ref ref) {
final argument = this.argument as String?;
return sandboxSourceUriForTab(ref, tabId: argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Uri? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Uri?>(value),
);
}
@override
bool operator ==(Object other) {
return other is SandboxSourceUriForTabProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$sandboxSourceUriForTabHash() =>
r'62f55eefd22377109080ea328c9ac0c00a0f7196';
/// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case).
final class SandboxSourceUriForTabFamily extends $Family
with $FunctionalFamilyOverride<Uri?, String?> {
SandboxSourceUriForTabFamily._()
: super(
retry: null,
name: r'sandboxSourceUriForTabProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case).
SandboxSourceUriForTabProvider call({required String? tabId}) =>
SandboxSourceUriForTabProvider._(argument: tabId, from: this);
@override
String toString() => r'sandboxSourceUriForTabProvider';
}
@ProviderFor(sandboxCaptureStore)
final sandboxCaptureStoreProvider = SandboxCaptureStoreProvider._();
final class SandboxCaptureStoreProvider
extends
$FunctionalProvider<
SandboxCaptureStore,
SandboxCaptureStore,
SandboxCaptureStore
>
with $Provider<SandboxCaptureStore> {
SandboxCaptureStoreProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sandboxCaptureStoreProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxCaptureStoreHash();
@$internal
@override
$ProviderElement<SandboxCaptureStore> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
SandboxCaptureStore create(Ref ref) {
return sandboxCaptureStore(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SandboxCaptureStore value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SandboxCaptureStore>(value),
);
}
}
String _$sandboxCaptureStoreHash() =>
r'59cf9cfe1e6727ea2c049ed60e7e69cfe6e55d03';
@ProviderFor(sandboxCaptureErrors)
final sandboxCaptureErrorsProvider = SandboxCaptureErrorsProvider._();
final class SandboxCaptureErrorsProvider
extends
$FunctionalProvider<
AsyncValue<SandboxCaptureError>,
SandboxCaptureError,
Stream<SandboxCaptureError>
>
with
$FutureModifier<SandboxCaptureError>,
$StreamProvider<SandboxCaptureError> {
SandboxCaptureErrorsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sandboxCaptureErrorsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxCaptureErrorsHash();
@$internal
@override
$StreamProviderElement<SandboxCaptureError> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<SandboxCaptureError> create(Ref ref) {
return sandboxCaptureErrors(ref);
}
}
String _$sandboxCaptureErrorsHash() =>
r'd6ef9ebba2515f8177a380b822f344128e4ea8c4';
/// Orchestrates sandbox capture browsing:
///
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
/// `onSandboxNewTab`) and runs the capture pipeline.
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
/// in sync with the `capture_tab` table via a DAO subscription.
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
///
/// This provider is kept alive for the lifetime of the app; `build()` wires
/// up the subscriptions and returns nothing interesting.
@ProviderFor(SandboxCaptureController)
final sandboxCaptureControllerProvider = SandboxCaptureControllerProvider._();
/// Orchestrates sandbox capture browsing:
///
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
/// `onSandboxNewTab`) and runs the capture pipeline.
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
/// in sync with the `capture_tab` table via a DAO subscription.
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
///
/// This provider is kept alive for the lifetime of the app; `build()` wires
/// up the subscriptions and returns nothing interesting.
final class SandboxCaptureControllerProvider
extends $NotifierProvider<SandboxCaptureController, void> {
/// Orchestrates sandbox capture browsing:
///
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
/// `onSandboxNewTab`) and runs the capture pipeline.
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
/// in sync with the `capture_tab` table via a DAO subscription.
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
///
/// This provider is kept alive for the lifetime of the app; `build()` wires
/// up the subscriptions and returns nothing interesting.
SandboxCaptureControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sandboxCaptureControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxCaptureControllerHash();
@$internal
@override
SandboxCaptureController create() => SandboxCaptureController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$sandboxCaptureControllerHash() =>
r'fb582cb09504dafd1b9addc1bfc0e6f58fabc9b1';
/// Orchestrates sandbox capture browsing:
///
/// 1. Listens for dispatch events from Kotlin (`onSandboxLinkClick`,
/// `onSandboxNewTab`) and runs the capture pipeline.
/// 2. Keeps the native `SandboxCaptureRegistry` and the on-disk JSON mirror
/// in sync with the `capture_tab` table via a DAO subscription.
/// 3. Listens on `CaptureServer.retryRequests` and re-runs failed captures.
///
/// This provider is kept alive for the lifetime of the app; `build()` wires
/// up the subscriptions and returns nothing interesting.
abstract class _$SandboxCaptureController 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(MetaSearchController)
final metaSearchControllerProvider = MetaSearchControllerProvider._();
final class MetaSearchControllerProvider
extends $NotifierProvider<MetaSearchController, MetaSearchState> {
MetaSearchControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'metaSearchControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$metaSearchControllerHash();
@$internal
@override
MetaSearchController create() => MetaSearchController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(MetaSearchState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<MetaSearchState>(value),
);
}
}
String _$metaSearchControllerHash() =>
r'b040527dfb49d9407fe0ea22169f5961d1faef13';
abstract class _$MetaSearchController extends $Notifier<MetaSearchState> {
MetaSearchState build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<MetaSearchState, MetaSearchState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<MetaSearchState, MetaSearchState>,
MetaSearchState,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// Persisted vertical scroll offset of the web-search results list. Kept
/// alive (like [MetaSearchController]) so returning to the search screen
/// after opening a result restores the user's place instead of jumping
/// back to the top. Reset to 0 on every fresh submit and on reset().
@ProviderFor(WebSearchScrollOffset)
final webSearchScrollOffsetProvider = WebSearchScrollOffsetProvider._();
/// Persisted vertical scroll offset of the web-search results list. Kept
/// alive (like [MetaSearchController]) so returning to the search screen
/// after opening a result restores the user's place instead of jumping
/// back to the top. Reset to 0 on every fresh submit and on reset().
final class WebSearchScrollOffsetProvider
extends $NotifierProvider<WebSearchScrollOffset, double> {
/// Persisted vertical scroll offset of the web-search results list. Kept
/// alive (like [MetaSearchController]) so returning to the search screen
/// after opening a result restores the user's place instead of jumping
/// back to the top. Reset to 0 on every fresh submit and on reset().
WebSearchScrollOffsetProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'webSearchScrollOffsetProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$webSearchScrollOffsetHash();
@$internal
@override
WebSearchScrollOffset create() => WebSearchScrollOffset();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(double value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<double>(value),
);
}
}
String _$webSearchScrollOffsetHash() =>
r'8d622d93aba7712b2bf0e6da7cbde6c90ee00c07';
/// Persisted vertical scroll offset of the web-search results list. Kept
/// alive (like [MetaSearchController]) so returning to the search screen
/// after opening a result restores the user's place instead of jumping
/// back to the top. Reset to 0 on every fresh submit and on reset().
abstract class _$WebSearchScrollOffset extends $Notifier<double> {
double build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<double, double>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<double, double>,
double,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,62 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
part 'captured_page_state.g.dart';
enum CapturedPageStatus { capturing, downloading, ready, downloadFailed }
@CopyWith()
class CapturedPageState with FastEquatable {
final Uri sourceUrl;
final CapturedPageStatus status;
final String? captureId;
final Uri? finalUrl;
final String? filename;
/// Capture engine used: `singlefile` or `shot-scraper`.
final String? method;
/// Engine-specific selector — preset (singlefile) or mode (shot-scraper).
final String? variant;
/// MIME type returned by the backend (e.g. `text/html`, `application/pdf`,
/// `image/png`). Used to choose the right file extension and to pick the
/// right viewer when opening the artifact locally.
final String? contentType;
final int? byteLength;
final String? localPath;
final String? downloadToken;
final String? errorMessage;
CapturedPageState({
required this.sourceUrl,
required this.status,
this.captureId,
this.finalUrl,
this.filename,
this.method,
this.variant,
this.contentType,
this.byteLength,
this.localPath,
this.downloadToken,
this.errorMessage,
});
@override
List<Object?> get hashParameters => [
sourceUrl,
status,
captureId,
finalUrl,
filename,
method,
variant,
contentType,
byteLength,
localPath,
downloadToken,
errorMessage,
];
}
@@ -0,0 +1,184 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'captured_page_state.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$CapturedPageStateCWProxy {
CapturedPageState sourceUrl(Uri sourceUrl);
CapturedPageState status(CapturedPageStatus status);
CapturedPageState captureId(String? captureId);
CapturedPageState finalUrl(Uri? finalUrl);
CapturedPageState filename(String? filename);
CapturedPageState method(String? method);
CapturedPageState variant(String? variant);
CapturedPageState contentType(String? contentType);
CapturedPageState byteLength(int? byteLength);
CapturedPageState localPath(String? localPath);
CapturedPageState downloadToken(String? downloadToken);
CapturedPageState errorMessage(String? errorMessage);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `CapturedPageState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// CapturedPageState(...).copyWith(id: 12, name: "My name")
/// ```
CapturedPageState call({
Uri sourceUrl,
CapturedPageStatus status,
String? captureId,
Uri? finalUrl,
String? filename,
String? method,
String? variant,
String? contentType,
int? byteLength,
String? localPath,
String? downloadToken,
String? errorMessage,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfCapturedPageState.copyWith(...)` or call `instanceOfCapturedPageState.copyWith.fieldName(value)` for a single field.
class _$CapturedPageStateCWProxyImpl implements _$CapturedPageStateCWProxy {
const _$CapturedPageStateCWProxyImpl(this._value);
final CapturedPageState _value;
@override
CapturedPageState sourceUrl(Uri sourceUrl) => call(sourceUrl: sourceUrl);
@override
CapturedPageState status(CapturedPageStatus status) => call(status: status);
@override
CapturedPageState captureId(String? captureId) => call(captureId: captureId);
@override
CapturedPageState finalUrl(Uri? finalUrl) => call(finalUrl: finalUrl);
@override
CapturedPageState filename(String? filename) => call(filename: filename);
@override
CapturedPageState method(String? method) => call(method: method);
@override
CapturedPageState variant(String? variant) => call(variant: variant);
@override
CapturedPageState contentType(String? contentType) =>
call(contentType: contentType);
@override
CapturedPageState byteLength(int? byteLength) => call(byteLength: byteLength);
@override
CapturedPageState localPath(String? localPath) => call(localPath: localPath);
@override
CapturedPageState downloadToken(String? downloadToken) =>
call(downloadToken: downloadToken);
@override
CapturedPageState errorMessage(String? errorMessage) =>
call(errorMessage: errorMessage);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `CapturedPageState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// CapturedPageState(...).copyWith(id: 12, name: "My name")
/// ```
CapturedPageState call({
Object? sourceUrl = const $CopyWithPlaceholder(),
Object? status = const $CopyWithPlaceholder(),
Object? captureId = const $CopyWithPlaceholder(),
Object? finalUrl = const $CopyWithPlaceholder(),
Object? filename = const $CopyWithPlaceholder(),
Object? method = const $CopyWithPlaceholder(),
Object? variant = const $CopyWithPlaceholder(),
Object? contentType = const $CopyWithPlaceholder(),
Object? byteLength = const $CopyWithPlaceholder(),
Object? localPath = const $CopyWithPlaceholder(),
Object? downloadToken = const $CopyWithPlaceholder(),
Object? errorMessage = const $CopyWithPlaceholder(),
}) {
return CapturedPageState(
sourceUrl: sourceUrl == const $CopyWithPlaceholder() || sourceUrl == null
? _value.sourceUrl
// ignore: cast_nullable_to_non_nullable
: sourceUrl as Uri,
status: status == const $CopyWithPlaceholder() || status == null
? _value.status
// ignore: cast_nullable_to_non_nullable
: status as CapturedPageStatus,
captureId: captureId == const $CopyWithPlaceholder()
? _value.captureId
// ignore: cast_nullable_to_non_nullable
: captureId as String?,
finalUrl: finalUrl == const $CopyWithPlaceholder()
? _value.finalUrl
// ignore: cast_nullable_to_non_nullable
: finalUrl as Uri?,
filename: filename == const $CopyWithPlaceholder()
? _value.filename
// ignore: cast_nullable_to_non_nullable
: filename as String?,
method: method == const $CopyWithPlaceholder()
? _value.method
// ignore: cast_nullable_to_non_nullable
: method as String?,
variant: variant == const $CopyWithPlaceholder()
? _value.variant
// ignore: cast_nullable_to_non_nullable
: variant as String?,
contentType: contentType == const $CopyWithPlaceholder()
? _value.contentType
// ignore: cast_nullable_to_non_nullable
: contentType as String?,
byteLength: byteLength == const $CopyWithPlaceholder()
? _value.byteLength
// ignore: cast_nullable_to_non_nullable
: byteLength as int?,
localPath: localPath == const $CopyWithPlaceholder()
? _value.localPath
// ignore: cast_nullable_to_non_nullable
: localPath as String?,
downloadToken: downloadToken == const $CopyWithPlaceholder()
? _value.downloadToken
// ignore: cast_nullable_to_non_nullable
: downloadToken as String?,
errorMessage: errorMessage == const $CopyWithPlaceholder()
? _value.errorMessage
// ignore: cast_nullable_to_non_nullable
: errorMessage as String?,
);
}
}
extension $CapturedPageStateCopyWith on CapturedPageState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfCapturedPageState.copyWith(...)` or `instanceOfCapturedPageState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$CapturedPageStateCWProxy get copyWith =>
_$CapturedPageStateCWProxyImpl(this);
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// Capture pipeline selector. Each choice maps to a (method, variant) pair
/// understood by the search backend's capture clients.
enum FetchMethodChoice {
/// Trafilatura — extracted reader-mode text + metadata. Not a "capture"
/// per se; goes through the `fetchPage` command.
trafilatura(method: null, variant: null),
/// SingleFile — self-contained HTML archive.
singlefileHtml(method: 'singlefile', variant: 'balanced'),
/// shot-scraper — PDF rendering.
shotScraperPdf(method: 'shot-scraper', variant: 'pdf'),
/// shot-scraper — PNG screenshot.
shotScraperPng(method: 'shot-scraper', variant: 'png');
const FetchMethodChoice({required this.method, required this.variant});
final String? method;
final String? variant;
static FetchMethodChoice? forCapture({
required String method,
required String variant,
}) {
for (final choice in FetchMethodChoice.values) {
if (choice.method == method && choice.variant == variant) {
return choice;
}
}
return null;
}
String get title => switch (this) {
FetchMethodChoice.trafilatura => 'Extracted Preview',
FetchMethodChoice.singlefileHtml => 'Full Page Capture',
FetchMethodChoice.shotScraperPdf => 'PDF Snapshot',
FetchMethodChoice.shotScraperPng => 'Image Snapshot',
};
String get shortLabel => switch (this) {
FetchMethodChoice.trafilatura => 'Preview',
FetchMethodChoice.singlefileHtml => 'Archive',
FetchMethodChoice.shotScraperPdf => 'PDF',
FetchMethodChoice.shotScraperPng => 'Image',
};
String get subtitle => switch (this) {
FetchMethodChoice.trafilatura =>
'Reader-optimized text and metadata for the in-app preview',
FetchMethodChoice.singlefileHtml =>
'Archive the full page with layout and assets for later use',
FetchMethodChoice.shotScraperPdf =>
'Render the page to a PDF for offline reading and sharing',
FetchMethodChoice.shotScraperPng =>
'Capture a full-page PNG screenshot of the rendered page',
};
IconData get icon => switch (this) {
FetchMethodChoice.trafilatura => Icons.description_outlined,
FetchMethodChoice.singlefileHtml => Icons.archive_outlined,
FetchMethodChoice.shotScraperPdf => Icons.picture_as_pdf_outlined,
FetchMethodChoice.shotScraperPng => Icons.image_outlined,
};
}
@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:search_backend/search_backend.dart';
import 'package:search_client/search_client.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/features/search_credits/domain/providers.dart';
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
part 'capture_artifact_downloader.g.dart';
class CaptureArtifactDownloadException implements Exception {
final String message;
final int? statusCode;
CaptureArtifactDownloadException(this.message, {this.statusCode});
@override
String toString() =>
'CaptureArtifactDownloadException: $message'
'${statusCode != null ? ' (status $statusCode)' : ''}';
}
class CaptureArtifactDownloader {
CaptureArtifactDownloader({
required BackendEndpoints endpoints,
required Directory storageDir,
http.Client? client,
}) : _endpoints = endpoints,
_storageDir = storageDir,
_client = client ?? http.Client();
final BackendEndpoints _endpoints;
final Directory _storageDir;
final http.Client _client;
/// Bound on the artifact download — covers connect + read combined. The
/// download path runs through the proxy client (which may be Tor), so a
/// stuck connection would otherwise leave the UI in `downloading` forever.
static const _downloadTimeout = Duration(seconds: 60);
Future<String> download(CaptureArtifactReceipt receipt) async {
await _storageDir.create(recursive: true);
final extension = captureFileExtension(receipt.contentType);
if (extension == null) {
// No on-disk extension we know how to serve back to Gecko later — the
// loopback capture server only probes a fixed set of extensions and an
// `.bin` artifact would be orphaned on disk. Surface the failure
// upstream instead of silently writing a file that can never load.
throw CaptureArtifactDownloadException(
'unsupported capture content type: ${receipt.contentType}',
);
}
final targetPath = p.join(
_storageDir.path,
'${receipt.captureId}$extension',
);
final tmpPath = '$targetPath.part';
final target = File(targetPath);
if (await target.exists()) {
return targetPath;
}
final uri = _endpoints.captureDownload(receipt.captureId);
final request = http.Request('GET', uri);
request.headers[HttpHeaders.authorizationHeader] =
'Bearer ${receipt.downloadToken}';
final streamed = await _client.send(request).timeout(_downloadTimeout);
if (streamed.statusCode != HttpStatus.ok) {
throw CaptureArtifactDownloadException(
'capture artifact download failed',
statusCode: streamed.statusCode,
);
}
final tmp = File(tmpPath);
final sink = tmp.openWrite();
try {
await streamed.stream.pipe(sink).timeout(_downloadTimeout);
} catch (e) {
await sink.close();
if (await tmp.exists()) {
await tmp.delete();
}
rethrow;
}
await tmp.rename(targetPath);
return targetPath;
}
}
/// Directory under the app temp area where capture artifacts are stored.
Directory captureStorageDirectory() {
return Directory(
p.join(
filesystem.tempDir.path,
'web_search_captures',
filesystem.selectedProfile.uuid,
),
);
}
/// Single source of truth for the capture artifact MIME ↔ extension mapping.
///
/// The capture server's loopback handler probes for exactly these extensions
/// on disk (see [resolveCaptureExtensions]) and looks up the response
/// `Content-Type` against this list (see [captureContentTypeForExtension]),
/// so adding a new artifact format only requires editing this one table.
const _captureArtifactTypes = <({String mime, String extension})>[
(mime: 'text/html', extension: '.html'),
(mime: 'application/pdf', extension: '.pdf'),
(mime: 'image/png', extension: '.png'),
(mime: 'image/jpeg', extension: '.jpg'),
];
/// File extensions the capture server should probe when resolving a stored
/// artifact (HTML + `.jpeg` alias for `image/jpeg`).
const captureSupportedExtensions = <String>[
'.html',
'.pdf',
'.png',
'.jpg',
'.jpeg',
];
/// Maps a capture receipt's MIME [contentType] to the on-disk extension we
/// store the artifact under. The extension is also what the loopback capture
/// server uses to pick the right `Content-Type` when serving back to Gecko.
///
/// Returns `null` for an unknown content type — the caller (downloader)
/// surfaces this as a download error rather than writing an orphan `.bin`
/// the loopback server can't serve.
String? captureFileExtension(String? contentType) {
if (contentType == null) return null;
final mime = contentType.split(';').first.trim().toLowerCase();
for (final type in _captureArtifactTypes) {
if (type.mime == mime) return type.extension;
}
return null;
}
/// True if [contentType] designates an HTML artifact. The sandbox-capture
/// flow (loopback redirects, retry loader, link-click interception) only
/// applies to HTML — PDF / PNG artifacts are loaded directly in a regular
/// tab so Gecko's built-in viewer renders them.
bool isHtmlCaptureContentType(String? contentType) {
if (contentType == null) return false;
return contentType.split(';').first.trim().toLowerCase() == 'text/html';
}
/// Snapshot of the host display, fed to the capture engines as
/// `CaptureDimensions` so PDF/PNG renders match the user's viewport.
///
/// `mobile` is always `true` so captures emulate a mobile viewport.
/// `colorScheme` follows the platform brightness so dark-mode sites render
/// correctly.
CaptureDimensions currentDisplayCaptureDimensions() {
final view =
PlatformDispatcher.instance.implicitView ??
PlatformDispatcher.instance.views.first;
final dpr = view.devicePixelRatio;
final physical = view.physicalSize;
final colorScheme =
PlatformDispatcher.instance.platformBrightness == Brightness.dark
? 'dark'
: 'light';
if (dpr <= 0 || physical.width <= 0 || physical.height <= 0) {
return CaptureDimensions(mobile: true, colorScheme: colorScheme);
}
final logicalWidth = (physical.width / dpr).round();
final logicalHeight = (physical.height / dpr).round();
return CaptureDimensions(
width: logicalWidth,
height: logicalHeight,
screenWidth: logicalWidth,
screenHeight: logicalHeight,
dpi: dpr,
mobile: true,
colorScheme: colorScheme,
);
}
/// Inverse of [captureFileExtension] — used by the loopback capture server
/// to choose a `Content-Type` header from a stored artifact's extension.
/// `.html` carries a charset; binary types don't.
String? captureContentTypeForExtension(String extension) {
final ext = extension.toLowerCase();
if (ext == '.jpeg') return 'image/jpeg';
for (final type in _captureArtifactTypes) {
if (type.extension == ext) {
return type.mime == 'text/html' ? 'text/html; charset=utf-8' : type.mime;
}
}
return null;
}
@Riverpod(keepAlive: true)
CaptureArtifactDownloader captureArtifactDownloader(Ref ref) {
return CaptureArtifactDownloader(
endpoints: ref.watch(searchBackendEndpointsProvider),
storageDir: captureStorageDirectory(),
client: ref.watch(searchProxyHttpClientProvider),
);
}
@@ -0,0 +1,58 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'capture_artifact_downloader.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(captureArtifactDownloader)
final captureArtifactDownloaderProvider = CaptureArtifactDownloaderProvider._();
final class CaptureArtifactDownloaderProvider
extends
$FunctionalProvider<
CaptureArtifactDownloader,
CaptureArtifactDownloader,
CaptureArtifactDownloader
>
with $Provider<CaptureArtifactDownloader> {
CaptureArtifactDownloaderProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'captureArtifactDownloaderProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$captureArtifactDownloaderHash();
@$internal
@override
$ProviderElement<CaptureArtifactDownloader> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
CaptureArtifactDownloader create(Ref ref) {
return captureArtifactDownloader(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CaptureArtifactDownloader value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CaptureArtifactDownloader>(value),
);
}
}
String _$captureArtifactDownloaderHash() =>
r'f746015082a68afc71e2af2e37ac83e3cc856891';
@@ -0,0 +1,631 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
part 'capture_server.g.dart';
// Extensions kept in lock-step with [captureFileExtension] in
// capture_artifact_downloader.dart — the downloader writes the artifact under
// one of these and this server probes for the same set.
class RetryRequest {
final String tabId;
final String captureId;
const RetryRequest({required this.tabId, required this.captureId});
}
/// Loopback HTTP server that exposes locally-stored capture HTML to the
/// in-app GeckoView.
///
/// Gecko rejects `content://` (PDF-only) and `file://` (blocked scheme), so
/// captures are served over `http://127.0.0.1:<port>/captures/<id>.html?t=<token>`.
/// Every published capture gets a fresh random token — other apps binding
/// to the same loopback cannot enumerate captures without guessing it.
///
/// The server also serves a zero-JS "Capturing…" loader page at
/// `/loader?tab=<tabId>&capture=<captureId>[&err=1]`. Sandbox tab navigation
/// is redirected to this loader while the capture is in flight; a
/// `meta http-equiv="refresh"` tag loops until the capture is ready, at which
/// point the loader responds with a `Refresh: 0; url=<captureUrl>` header so
/// GeckoView transitions to the real capture without JavaScript.
class _CaptureEntry {
_CaptureEntry({required this.token});
final String token;
// Cached path to the resolved artifact, populated by the first probe so
// hot paths (loader long-poll wakeups) don't keep stat'ing the FS for
// five extensions per check.
File? cachedFile;
}
class CaptureServer {
CaptureServer({required Directory storageDir}) : _storageDir = storageDir;
final Directory _storageDir;
final Map<String, _CaptureEntry> _entriesById = {};
final Set<String> _failedIds = {};
// Completer per captureId, completed by publish()/markFailed() so loader
// long-polls wake up immediately instead of busy-looping a 200ms poll.
// Recreated after each completion so a subsequent retry can wait again.
final Map<String, Completer<void>> _waiters = {};
final _random = Random.secure();
final _retryController = StreamController<RetryRequest>.broadcast();
HttpServer? _server;
Future<HttpServer>? _starting;
Completer<void> _waiterFor(String captureId) {
return _waiters.putIfAbsent(captureId, Completer<void>.new);
}
void _signalWaiter(String captureId) {
final completer = _waiters.remove(captureId);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
Stream<RetryRequest> get retryRequests => _retryController.stream;
Future<HttpServer> _ensureStarted() async {
final existing = _server;
if (existing != null) return existing;
final pending = _starting;
if (pending != null) return pending;
// Track success and failure: a rejected bind() must clear `_starting`,
// otherwise the stale rejected future is handed back to every subsequent
// caller and the server can never recover (e.g. transient EADDRINUSE).
final future = HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_starting = future
.then((server) {
_server = server;
unawaited(_serve(server));
return server;
})
.whenComplete(() => _starting = null);
return _starting!;
}
Future<void> _serve(HttpServer server) async {
await for (final request in server) {
unawaited(_handle(request));
}
}
Future<void> _handle(HttpRequest request) async {
try {
final segments = request.uri.pathSegments;
if (segments.isEmpty) {
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
return;
}
switch (segments[0]) {
case 'captures':
if (request.method != 'GET' || segments.length != 2) {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleCapture(request, segments[1]);
return;
case 'loader':
if (segments.length == 1) {
if (request.method != 'GET') {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleLoader(request);
return;
}
if (segments.length == 2 && segments[1] == 'wait') {
if (request.method != 'GET') {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleLoaderWait(request);
return;
}
if (segments.length == 2 && segments[1] == 'retry') {
if (request.method != 'POST') {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleRetry(request);
return;
}
await _emptyResponse(request, HttpStatus.notFound);
return;
default:
await _emptyResponse(request, HttpStatus.notFound);
return;
}
} catch (_) {
try {
await request.response.close();
} catch (_) {}
}
}
Future<void> _handleCapture(HttpRequest request, String lastSegment) async {
final extIndex = lastSegment.lastIndexOf('.');
final captureId = extIndex >= 0
? lastSegment.substring(0, extIndex)
: lastSegment;
if (!_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.notFound);
return;
}
final entry = _entriesById[captureId];
final token = request.uri.queryParameters['t'];
if (entry == null || token == null || token != entry.token) {
await _emptyResponse(request, HttpStatus.forbidden);
return;
}
final file = await _resolveCaptureFile(captureId);
if (file == null) {
await _emptyResponse(request, HttpStatus.notFound);
return;
}
final extension = p.extension(file.path).toLowerCase();
final mime =
captureContentTypeForExtension(extension) ?? 'application/octet-stream';
final isHtml = extension == '.html';
final length = await file.length();
request.response.statusCode = HttpStatus.ok;
request.response.headers
..set(HttpHeaders.contentTypeHeader, mime)
..contentLength = length
..set(HttpHeaders.cacheControlHeader, 'no-store')
..set('X-Content-Type-Options', 'nosniff');
if (isHtml) {
// HTML is the only artifact type that can execute script or load
// subresources — clamp it down. PDF/PNG bytes are inert; the browser's
// built-in viewer handles them and CSP would just confuse it.
request.response.headers.set('Content-Security-Policy', _captureCsp);
}
await request.response.addStream(file.openRead());
await request.response.close();
}
/// Resolves the on-disk artifact for [captureId] and caches the result on
/// the entry so subsequent lookups skip the five-extension stat loop. The
/// cache is re-validated on every lookup via a cheap `exists()` so a file
/// removed out-of-band (cache cleanup, manual delete) is detected and the
/// stat loop is re-run; without this, the server would happily hand
/// `request.response.addStream(file.openRead())` a missing file and
/// surface as a mid-stream error rather than the expected 404.
Future<File?> _resolveCaptureFile(String captureId) async {
final entry = _entriesById[captureId];
if (entry == null) return null;
final cached = entry.cachedFile;
if (cached != null) {
if (await cached.exists()) return cached;
entry.cachedFile = null;
}
for (final ext in captureSupportedExtensions) {
final file = File(p.join(_storageDir.path, '$captureId$ext'));
if (await file.exists()) {
entry.cachedFile = file;
return file;
}
}
return null;
}
File? _resolveCaptureFileSync(String captureId) {
final entry = _entriesById[captureId];
if (entry == null) return null;
final cached = entry.cachedFile;
if (cached != null) {
if (cached.existsSync()) return cached;
entry.cachedFile = null;
}
for (final ext in captureSupportedExtensions) {
final file = File(p.join(_storageDir.path, '$captureId$ext'));
if (file.existsSync()) {
entry.cachedFile = file;
return file;
}
}
return null;
}
Future<void> _handleLoader(HttpRequest request) async {
final tabId = request.uri.queryParameters['tab'];
final captureId = request.uri.queryParameters['capture'];
final err = request.uri.queryParameters['err'] == '1';
if (tabId == null ||
captureId == null ||
!_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.notFound);
return;
}
// Render the loader shell immediately. Status transitions are driven
// by an inline JS long-poll against /loader/wait — no meta-refresh,
// no full-page reloads while the user is staring at the spinner.
await _writeLoaderHtml(
request,
body: _loaderShellBody(initialError: err),
tabId: tabId,
captureId: captureId,
);
}
/// JSON long-poll endpoint consumed by the loader shell. Holds the
/// connection open up to ~25s, returning as soon as the capture
/// transitions to `ready` or `failed`. Idle clients reconnect on close,
/// so we keep timeouts well under typical proxy buffering windows.
///
/// Wakes up event-driven via a per-id [Completer] signaled by
/// [publish]/[markFailed]; the older revision busy-polled `isReady` every
/// 200ms which burned CPU on long captures.
Future<void> _handleLoaderWait(HttpRequest request) async {
final captureId = request.uri.queryParameters['capture'];
if (captureId == null || !_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.badRequest);
return;
}
String status;
String? captureUrl;
if (_failedIds.contains(captureId)) {
status = 'failed';
} else if (isReady(captureId)) {
status = 'ready';
} else {
// Race-free: register the waiter before re-checking ready/failed.
// If publish() fires between the early returns above and here, the
// waiter has already been completed via _signalWaiter.
final completer = _waiterFor(captureId);
try {
await completer.future.timeout(const Duration(seconds: 25));
} on TimeoutException {
// Fall through — loader script reconnects on close.
}
if (_failedIds.contains(captureId)) {
status = 'failed';
} else if (isReady(captureId)) {
status = 'ready';
} else {
status = 'pending';
}
}
if (status == 'ready') {
final server = _server;
final entry = _entriesById[captureId];
if (server != null && entry != null) {
captureUrl = _buildCaptureUrl(
server.port,
captureId,
entry.token,
).toString();
}
}
final body = jsonEncode({
'status': status,
if (captureUrl != null) 'url': captureUrl,
});
request.response.statusCode = HttpStatus.ok;
request.response.headers
..contentType = ContentType('application', 'json', charset: 'utf-8')
..set(HttpHeaders.cacheControlHeader, 'no-store');
request.response.write(body);
await request.response.close();
}
Future<void> _handleRetry(HttpRequest request) async {
final tabId = request.uri.queryParameters['tab'];
final captureId = request.uri.queryParameters['capture'];
if (tabId == null ||
captureId == null ||
!_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.badRequest);
return;
}
_retryController.add(RetryRequest(tabId: tabId, captureId: captureId));
// The loader script in the *current* page picks up the new state via its
// long-poll, so a Refresh header (or full reload) would just discard the
// tab's in-flight fetch. Reply with an empty 204 — the client doesn't
// need the body.
await _emptyResponse(request, HttpStatus.noContent);
}
Future<void> _writeLoaderHtml(
HttpRequest request, {
required String body,
required String tabId,
required String captureId,
}) async {
request.response.statusCode = HttpStatus.ok;
request.response.headers
..contentType = ContentType('text', 'html', charset: 'utf-8')
..set(HttpHeaders.cacheControlHeader, 'no-store')
..set('X-Content-Type-Options', 'nosniff')
..set('Content-Security-Policy', _loaderCsp);
final tabIdJson = jsonEncode(tabId);
final captureIdJson = jsonEncode(captureId);
final html =
'<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>Capturing…</title><style>$_loaderStyles</style>'
'</head><body>$body'
'<script>window.__TAB_ID__=$tabIdJson;'
'window.__CAPTURE_ID__=$captureIdJson;</script>'
'<script>$_loaderScript</script>'
'</body></html>';
request.response.write(html);
await request.response.close();
}
Future<void> _emptyResponse(HttpRequest request, int status) async {
request.response.statusCode = status;
await request.response.close();
}
/// Registers a capture and returns a `http://127.0.0.1:<port>/...` URL
/// that can be loaded in a GeckoView tab.
Future<Uri> publish(String captureId) async {
final server = await _ensureStarted();
_failedIds.remove(captureId);
final entry = _entriesById.putIfAbsent(
captureId,
() => _CaptureEntry(token: _generateToken()),
);
// Wake any in-flight long-poll for this id — the artifact is now
// resolvable, so the poll can return `ready` immediately.
_signalWaiter(captureId);
return _buildCaptureUrl(server.port, captureId, entry.token);
}
/// Marks a capture as failed so any in-flight loader long-polls return
/// immediately with `status: 'failed'`.
void markFailed(String captureId) {
_failedIds.add(captureId);
_signalWaiter(captureId);
}
/// Clears any prior failed flag for the given capture id (e.g. before a
/// retry).
void clearFailed(String captureId) {
_failedIds.remove(captureId);
}
/// Ensures the server is running and returns its port. Useful when callers
/// need to build a loader URL before any capture has been published.
Future<int> ensureStarted() async {
final server = await _ensureStarted();
return server.port;
}
/// Builds the loader URL for the given tab + capture. Also ensures the
/// server is running so callers can use the returned port stably.
Future<Uri> loaderUrl({
required String tabId,
required String captureId,
bool error = false,
}) async {
final port = await ensureStarted();
return Uri(
scheme: 'http',
host: InternetAddress.loopbackIPv4.address,
port: port,
pathSegments: const ['loader'],
queryParameters: {
'tab': tabId,
'capture': captureId,
if (error) 'err': '1',
},
);
}
/// True if [publish] has been called for [captureId] and the artifact file
/// is present on disk. Used by the loader to decide between meta-refresh
/// and redirect.
bool isReady(String captureId) {
final entry = _entriesById[captureId];
if (entry == null) return false;
return _resolveCaptureFileSync(captureId) != null;
}
void revoke(String captureId) {
_entriesById.remove(captureId);
_failedIds.remove(captureId);
_signalWaiter(captureId);
}
Future<void> stop() async {
final server = _server;
_server = null;
_starting = null;
_entriesById.clear();
_failedIds.clear();
// Release every pending long-poll so the request handler can finish.
for (final completer in _waiters.values) {
if (!completer.isCompleted) completer.complete();
}
_waiters.clear();
await server?.close(force: true);
await _retryController.close();
}
Uri _buildCaptureUrl(int port, String captureId, String token) {
// If the artifact is on disk, use its real extension so Gecko picks the
// right viewer (e.g. PDF.js for `.pdf`). Pre-publish (no file yet) we
// fall back to `.html`; the loader long-poll re-resolves the URL once
// the artifact lands.
final file = _resolveCaptureFileSync(captureId);
final ext = file != null ? p.extension(file.path).toLowerCase() : '.html';
return Uri(
scheme: 'http',
host: InternetAddress.loopbackIPv4.address,
port: port,
pathSegments: ['captures', '$captureId$ext'],
queryParameters: {'t': token},
);
}
String _generateToken() {
final bytes = List<int>.generate(24, (_) => _random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
static final _captureIdPattern = RegExp(r'^[A-Za-z0-9_-]+$');
static const _captureCsp =
"default-src 'none'; "
"script-src 'none'; "
"style-src 'unsafe-inline' data:; "
"img-src data: blob:; "
"font-src data:; "
"frame-ancestors 'none'; "
"base-uri 'none'; "
"form-action 'none'";
// The loader is server-controlled HTML on the loopback origin only — it
// never loads remote subresources. Inline script/style are required for
// the long-poll JS and the spinner CSS.
static const _loaderCsp =
"default-src 'none'; "
"script-src 'unsafe-inline'; "
"style-src 'unsafe-inline'; "
"connect-src 'self'; "
"img-src data:; "
"frame-ancestors 'none'; "
"base-uri 'none'; "
"form-action 'self'";
static const _loaderStyles =
'html,body{height:100%}'
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,'
'sans-serif;display:flex;align-items:center;justify-content:center;'
'min-height:100vh;margin:0;background:#0b0d10;color:#e6e8eb}'
'main{text-align:center;max-width:480px;padding:32px;opacity:0;'
'animation:fade .35s ease-out forwards}'
'@keyframes fade{to{opacity:1}}'
'.spinner{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;'
'border:3px solid rgba(255,255,255,.08);border-top-color:#7aa2f7;'
'animation:spin 1s linear infinite}'
'@keyframes spin{to{transform:rotate(360deg)}}'
'h1{font-size:18px;font-weight:600;margin:0 0 8px;letter-spacing:.2px}'
'p{margin:0;color:#9aa1a8;font-size:14px;line-height:1.5}'
'.dots::after{display:inline-block;width:1.2em;text-align:left;'
'animation:dots 1.4s steps(4,end) infinite;content:""}'
'@keyframes dots{0%{content:""}25%{content:"."}50%{content:".."}'
'75%{content:"..."}100%{content:""}}'
'.error h1{color:#f7768e}'
'.actions{margin-top:20px;display:flex;gap:8px;justify-content:center}'
'.btn{padding:10px 16px;border:0;border-radius:10px;cursor:pointer;'
'font:inherit;font-weight:600;background:#7aa2f7;color:#0b0d10}'
'.btn.secondary{background:transparent;color:#9aa1a8;'
'border:1px solid rgba(255,255,255,.12)}'
'.hidden{display:none}'
'@media(prefers-color-scheme:light){body{background:#f5f6f8;'
'color:#1a1d22}.spinner{border-color:rgba(0,0,0,.08);'
'border-top-color:#3b82f6}.btn{background:#3b82f6;color:white}'
'p{color:#52606b}}';
// Inline script for the loader shell. Reads window.__TAB_ID__ and
// window.__CAPTURE_ID__, long-polls /loader/wait, then either redirects
// to the ready capture or flips to the error pane.
//
// Network-error retries are capped (MAX_NET_ERRORS) so a tab whose capture
// service has gone away doesn't busy-loop forever — after the cap the
// loader gives up and shows the error pane with a Retry button (which
// resets the counter via showPending → poll()).
static const _loaderScript = r'''
(function(){
var pending=document.getElementById('pending');
var error=document.getElementById('error');
var retryBtn=document.getElementById('retry');
var tabId=window.__TAB_ID__;
var captureId=window.__CAPTURE_ID__;
var MAX_NET_ERRORS=15;
var netErrors=0;
function showError(){
if(pending)pending.classList.add('hidden');
if(error)error.classList.remove('hidden');
}
function showPending(){
netErrors=0;
if(error)error.classList.add('hidden');
if(pending)pending.classList.remove('hidden');
}
async function poll(){
try{
var r=await fetch('/loader/wait?tab='+encodeURIComponent(tabId)+
'&capture='+encodeURIComponent(captureId),{cache:'no-store'});
if(!r.ok){
if(++netErrors>=MAX_NET_ERRORS){showError();return}
await new Promise(function(res){setTimeout(res,2000)});return poll();
}
netErrors=0;
var data=await r.json();
if(data.status==='ready'&&data.url){location.replace(data.url);return}
if(data.status==='failed'){showError();return}
// pending timeout — reconnect immediately.
return poll();
}catch(e){
if(++netErrors>=MAX_NET_ERRORS){showError();return}
await new Promise(function(res){setTimeout(res,2000)});
return poll();
}
}
if(retryBtn){
retryBtn.addEventListener('click',function(){
showPending();
fetch('/loader/retry?tab='+encodeURIComponent(tabId)+
'&capture='+encodeURIComponent(captureId),
{method:'POST',cache:'no-store'}).catch(function(){});
poll();
});
}
if(error&&!error.classList.contains('hidden')){
// Started in error mode — wait for user retry.
return;
}
poll();
})();
''';
String _loaderShellBody({required bool initialError}) {
final pendingClass = initialError ? 'hidden' : '';
final errorClass = initialError ? 'error' : 'error hidden';
return '<main id="pending" class="$pendingClass">'
'<div class="spinner"></div>'
'<h1>Capturing page<span class="dots"></span></h1>'
'<p>Saving an offline copy. This usually takes a few seconds.</p>'
'</main>'
'<main id="error" class="$errorClass">'
'<h1>Capture failed</h1>'
'<p>The page could not be saved. Check the notification for details.</p>'
'<div class="actions">'
'<button id="retry" class="btn" type="button">Retry</button>'
'</div>'
'</main>';
}
}
@Riverpod(keepAlive: true)
CaptureServer captureServer(Ref ref) {
final server = CaptureServer(storageDir: captureStorageDirectory());
ref.onDispose(() {
unawaited(server.stop());
});
return server;
}
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'capture_server.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(captureServer)
final captureServerProvider = CaptureServerProvider._();
final class CaptureServerProvider
extends $FunctionalProvider<CaptureServer, CaptureServer, CaptureServer>
with $Provider<CaptureServer> {
CaptureServerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'captureServerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$captureServerHash();
@$internal
@override
$ProviderElement<CaptureServer> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
CaptureServer create(Ref ref) {
return captureServer(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CaptureServer value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CaptureServer>(value),
);
}
}
String _$captureServerHash() => r'ddbb9eff1b3d622a44a5ae9523e2f96a37b075fb';
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
show CaptureTabData;
/// Mirror of the capture_tab rows on disk under
/// `<profileDir>/files/sandbox_captures.json`. Kotlin reads this file during
/// pre-Gecko bootstrap, before tab restore can dispatch any load request.
///
/// The schema is intentionally tiny: only the fields Kotlin needs to build a
/// placeholder [SandboxEntry] with `redirectUrl = "about:blank"`. Dart
/// replaces those placeholders with real loopback URLs via
/// [SandboxCaptureApi.resetAll] once [CaptureServer] is running.
///
/// The [_version] field is written at the top of the JSON envelope. Kotlin
/// reads it and refuses to rehydrate any version higher than the one it
/// supports — a forward-incompatible write from a newer Dart side leaves
/// the registry empty for that cold start, which is the safe outcome
/// (sandbox tabs reopen as about:blank rather than loading live URLs).
/// Bump this in lockstep with the Kotlin `SUPPORTED_VERSION` constant.
const _version = 1;
class SandboxCaptureStore {
File _file() {
return File(
p.join(
filesystem.selectedProfileDir.path,
'files',
'sandbox_captures.json',
),
);
}
Future<void> write(List<CaptureTabData> rows) async {
final file = _file();
await file.parent.create(recursive: true);
final payload = jsonEncode({
'version': _version,
'entries': rows
.map((r) {
return {
'tabId': r.tabId,
'captureId': r.captureId,
'sourceUrl': r.sourceUrl,
'status': r.status,
};
})
.toList(growable: false),
});
final tmp = File('${file.path}.part');
await tmp.writeAsString(payload, flush: true);
await tmp.rename(file.path);
}
}