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,151 @@
/*
* 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/>.
*/
library;
/// Curated picker options for the web-search language and country selectors.
///
/// Codes intersect what Brave (`search_lang`/`country`) and Mojeek
/// (`lb`/`rb`) accept; English display names so the menu doesn't need a
/// per-locale translation pass. The protocol carries just the primary
/// ISO 639-1 / ISO 3166-1 alpha-2 codes — region qualifiers (`en-gb`,
/// `pt-br`, etc.) are reconstructed server-side from the language+country
/// pair.
class LanguageOption {
final String code; // ISO 639-1
final String name; // English display name
const LanguageOption(this.code, this.name);
}
class CountryOption {
final String code; // ISO 3166-1 alpha-2 (uppercase)
final String name; // English display name
const CountryOption(this.code, this.name);
}
const supportedLanguages = <LanguageOption>[
LanguageOption('ar', 'Arabic'),
LanguageOption('bg', 'Bulgarian'),
LanguageOption('bn', 'Bengali'),
LanguageOption('ca', 'Catalan'),
LanguageOption('cs', 'Czech'),
LanguageOption('da', 'Danish'),
LanguageOption('de', 'German'),
LanguageOption('el', 'Greek'),
LanguageOption('en', 'English'),
LanguageOption('es', 'Spanish'),
LanguageOption('et', 'Estonian'),
LanguageOption('eu', 'Basque'),
LanguageOption('fi', 'Finnish'),
LanguageOption('fr', 'French'),
LanguageOption('gl', 'Galician'),
LanguageOption('gu', 'Gujarati'),
LanguageOption('he', 'Hebrew'),
LanguageOption('hi', 'Hindi'),
LanguageOption('hr', 'Croatian'),
LanguageOption('hu', 'Hungarian'),
LanguageOption('id', 'Indonesian'),
LanguageOption('is', 'Icelandic'),
LanguageOption('it', 'Italian'),
LanguageOption('ja', 'Japanese'),
LanguageOption('kn', 'Kannada'),
LanguageOption('ko', 'Korean'),
LanguageOption('lt', 'Lithuanian'),
LanguageOption('lv', 'Latvian'),
LanguageOption('ml', 'Malayalam'),
LanguageOption('mr', 'Marathi'),
LanguageOption('ms', 'Malay'),
LanguageOption('nb', 'Norwegian'),
LanguageOption('nl', 'Dutch'),
LanguageOption('pa', 'Punjabi'),
LanguageOption('pl', 'Polish'),
LanguageOption('pt', 'Portuguese'),
LanguageOption('ro', 'Romanian'),
LanguageOption('ru', 'Russian'),
LanguageOption('sk', 'Slovak'),
LanguageOption('sl', 'Slovenian'),
LanguageOption('sr', 'Serbian'),
LanguageOption('sv', 'Swedish'),
LanguageOption('ta', 'Tamil'),
LanguageOption('te', 'Telugu'),
LanguageOption('th', 'Thai'),
LanguageOption('tr', 'Turkish'),
LanguageOption('uk', 'Ukrainian'),
LanguageOption('vi', 'Vietnamese'),
LanguageOption('zh', 'Chinese'),
];
const supportedCountries = <CountryOption>[
CountryOption('AR', 'Argentina'),
CountryOption('AT', 'Austria'),
CountryOption('AU', 'Australia'),
CountryOption('BE', 'Belgium'),
CountryOption('BR', 'Brazil'),
CountryOption('CA', 'Canada'),
CountryOption('CH', 'Switzerland'),
CountryOption('CL', 'Chile'),
CountryOption('CN', 'China'),
CountryOption('DE', 'Germany'),
CountryOption('DK', 'Denmark'),
CountryOption('ES', 'Spain'),
CountryOption('FI', 'Finland'),
CountryOption('FR', 'France'),
CountryOption('GB', 'United Kingdom'),
CountryOption('GR', 'Greece'),
CountryOption('HK', 'Hong Kong'),
CountryOption('ID', 'Indonesia'),
CountryOption('IN', 'India'),
CountryOption('IT', 'Italy'),
CountryOption('JP', 'Japan'),
CountryOption('KR', 'South Korea'),
CountryOption('MX', 'Mexico'),
CountryOption('MY', 'Malaysia'),
CountryOption('NL', 'Netherlands'),
CountryOption('NO', 'Norway'),
CountryOption('NZ', 'New Zealand'),
CountryOption('PH', 'Philippines'),
CountryOption('PL', 'Poland'),
CountryOption('PT', 'Portugal'),
CountryOption('RU', 'Russia'),
CountryOption('SA', 'Saudi Arabia'),
CountryOption('SE', 'Sweden'),
CountryOption('TR', 'Turkey'),
CountryOption('TW', 'Taiwan'),
CountryOption('US', 'United States'),
CountryOption('ZA', 'South Africa'),
];
LanguageOption? findLanguage(String? code) {
if (code == null) return null;
final lower = code.toLowerCase();
for (final l in supportedLanguages) {
if (l.code == lower) return l;
}
return null;
}
CountryOption? findCountry(String? code) {
if (code == null) return null;
final upper = code.toUpperCase();
for (final c in supportedCountries) {
if (c.code == upper) return c;
}
return null;
}
@@ -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);
}
}
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart';
import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart';
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
Future<void> showFetchMethodSheet(
BuildContext context, {
required Uri url,
required Future<void> Function(Uri url) onPreview,
required Future<void> Function(CapturedPageState captured) onOpenCapture,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) => _FetchMethodSheet(
url: url,
onPreview: onPreview,
onOpenCapture: onOpenCapture,
),
);
}
class _FetchMethodSheet extends ConsumerWidget {
final Uri url;
final Future<void> Function(Uri url) onPreview;
final Future<void> Function(CapturedPageState captured) onOpenCapture;
const _FetchMethodSheet({
required this.url,
required this.onPreview,
required this.onOpenCapture,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(metaSearchControllerProvider);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Fetch Page Data', style: textTheme.titleLarge),
const SizedBox(height: 4),
Text(
url.toString(),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 12),
for (final choice in FetchMethodChoice.values)
_MethodTile(
url: url,
choice: choice,
state: state,
onPreview: onPreview,
onOpenCapture: onOpenCapture,
),
],
),
),
);
}
}
class _MethodTile extends ConsumerWidget {
final Uri url;
final FetchMethodChoice choice;
final MetaSearchState state;
final Future<void> Function(Uri url) onPreview;
final Future<void> Function(CapturedPageState captured) onOpenCapture;
const _MethodTile({
required this.url,
required this.choice,
required this.state,
required this.onPreview,
required this.onOpenCapture,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final busy = state.isMethodBusy(url, choice);
final ready = state.isMethodReady(url, choice);
final captured = state.capturedPage(url, choice);
final failed = captured?.status == CapturedPageStatus.downloadFailed;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Icon(choice.icon, color: colorScheme.onSurfaceVariant),
title: Text(choice.title),
subtitle: Text(
failed
? (captured?.errorMessage ?? 'Download failed — tap to retry')
: choice.subtitle,
style: textTheme.bodySmall?.copyWith(
color: failed ? colorScheme.error : colorScheme.onSurfaceVariant,
),
),
trailing: _StatusIndicator(
busy: busy,
ready: ready,
failed: failed,
colorScheme: colorScheme,
),
onTap: busy ? null : () => _handleTap(context, ref, ready, failed, captured),
);
}
Future<void> _handleTap(
BuildContext context,
WidgetRef ref,
bool ready,
bool failed,
CapturedPageState? captured,
) async {
if (ready) {
Navigator.of(context).pop();
if (choice == FetchMethodChoice.trafilatura) {
await onPreview(url);
} else if (captured != null) {
await onOpenCapture(captured);
}
return;
}
Navigator.of(context).pop();
if (failed && captured != null) {
await ref
.read(metaSearchControllerProvider.notifier)
.retryCaptureDownload(url, choice);
return;
}
if (choice == FetchMethodChoice.trafilatura) {
await ref.read(metaSearchControllerProvider.notifier).fetchPage(url);
return;
}
await ref
.read(metaSearchControllerProvider.notifier)
.capturePage(
url,
choice: choice,
// Render at the device's actual viewport so PDF/PNG snapshots
// match what the user sees; singlefile ignores these fields.
dimensions: currentDisplayCaptureDimensions(),
);
}
}
class _StatusIndicator extends StatelessWidget {
final bool busy;
final bool ready;
final bool failed;
final ColorScheme colorScheme;
const _StatusIndicator({
required this.busy,
required this.ready,
required this.failed,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
if (busy) {
return const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
if (ready) {
return Icon(Icons.check_circle, color: colorScheme.primary);
}
if (failed) {
return Icon(Icons.refresh, color: colorScheme.error);
}
return Icon(
Icons.download_rounded,
color: colorScheme.onSurfaceVariant,
);
}
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.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/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/features/web_search/domain/services/capture_server.dart';
/// Parameters describing where a web-search-opened tab should land.
///
/// The search screen owns the user-visible selectors (tab type, container,
/// parent for child tabs) and threads them through here so the result-card
/// open path honours them — historically this path inherited the *currently
/// selected* tab's mode/container instead, ignoring the search UI.
class WebSearchOpenTarget {
final TabMode tabMode;
final TabContainerSelection containerSelection;
final String? parentId;
const WebSearchOpenTarget({
required this.tabMode,
required this.containerSelection,
this.parentId,
});
}
abstract interface class WebSearchTabOpener {
Future<void> open(
BuildContext context,
WidgetRef ref,
Uri uri, {
required WebSearchOpenTarget target,
});
Future<void> openCapture(
BuildContext context,
WidgetRef ref, {
required String captureId,
required Uri sourceUrl,
required WebSearchOpenTarget target,
String? contentType,
String? method,
String? variant,
});
}
final webSearchTabOpenerProvider = Provider<WebSearchTabOpener>(
(ref) => const _DefaultWebSearchTabOpener(),
);
final class _DefaultWebSearchTabOpener implements WebSearchTabOpener {
const _DefaultWebSearchTabOpener();
@override
Future<void> open(
BuildContext context,
WidgetRef ref,
Uri uri, {
required WebSearchOpenTarget target,
}) async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: target.tabMode,
parentId: target.parentId,
promptOnBackBehavior: ReturnToSearchTabBackPromptBehavior(
tabType: target.tabMode.toTabType(),
),
selectTab: true,
containerSelection: target.containerSelection,
);
// Push (not go/replace) so the SearchRoute stays underneath. Pressing
// back from the freshly opened tab pops back to the search results
// instead of closing the tab. metaSearchControllerProvider is
// keepAlive, so results survive the navigation either way.
if (context.mounted) {
const BrowserRoute().go(context);
}
}
@override
Future<void> openCapture(
BuildContext context,
WidgetRef ref, {
required String captureId,
required Uri sourceUrl,
required WebSearchOpenTarget target,
String? contentType,
String? method,
String? variant,
}) async {
// Never navigate to the real sourceUrl — Gecko's app-link resolution
// (e.g. github.com triggers the "Open in GitHub app?" dialog) fires
// before our interceptor can redirect, and a cancel falls back to
// loading the live site. Load the loopback capture URL directly.
final captureUrl = await ref.read(captureServerProvider).publish(captureId);
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: captureUrl,
tabMode: target.tabMode,
parentId: target.parentId,
containerSelection: target.containerSelection,
promptOnBackBehavior: ReturnToSearchTabBackPromptBehavior(
tabType: target.tabMode.toTabType(),
),
selectTab: true,
startLoading: false,
);
// Always register the capture root — even for PDF / PNG. The capture_tab
// DAO row is what (a) tells Kotlin's middleware to treat this tab as a
// sandbox tab so out-of-origin link navigations get intercepted, and
// (b) backs the address bar's source-URL display so the loopback URL
// doesn't leak. PDF and PNG viewers don't follow links in practice, so
// the extra interception is harmless.
await ref
.read(sandboxCaptureControllerProvider.notifier)
.registerRootCapture(
tabId: tabId,
receipt: CapturedPageReceiptLike(
captureId: captureId,
sourceUrl: sourceUrl,
),
method: method,
variant: variant,
);
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: captureUrl);
if (context.mounted) {
const BrowserRoute().go(context);
}
}
}
@@ -0,0 +1,94 @@
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart';
import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class PagePreviewScreen extends ConsumerWidget {
final Uri uri;
final WebSearchOpenTarget Function() resolveOpenTarget;
const PagePreviewScreen({
super.key,
required this.uri,
required this.resolveOpenTarget,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// The preview screen only depends on the document for this one URL and
// the matching result row. Selecting both via a record means unrelated
// controller updates (favicons, image streams, other URLs' fetches)
// don't rebuild the (potentially-large) Markdown body below.
final (document, result) = ref.watch(
metaSearchControllerProvider.select(
(s) => (
s.documentsByUrl[uri],
s.results.where((item) => item.url == uri).firstOrNull,
),
),
);
final title = result?.title ?? document?.metadata.title ?? uri.authority;
if (document == null) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: const FailureWidget(
title: 'Preview unavailable',
exception:
'Fetch the page from the result list before opening a preview.',
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: [
IconButton(
tooltip: 'Open in browser',
onPressed: () async {
await ref
.read(webSearchTabOpenerProvider)
.open(context, ref, uri, target: resolveOpenTarget());
},
icon: const Icon(Icons.open_in_new),
),
],
),
body: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UriBreadcrumb(
uri: uri,
icon: UrlIcon([uri], iconSize: 16, cacheOnly: true),
),
const SizedBox(height: 16),
SearchResultMetadataChips(
metadata: result?.metadata ?? const [],
pageMetadata: document.metadata,
),
if (result?.metadata case final metadata?
when metadata.isNotEmpty)
SearchResultMetadataExpandable(metadata: metadata),
const SizedBox(height: 16),
MarkdownBody(selectable: true, data: document.content),
],
),
);
},
),
);
}
}
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:intl/intl.dart';
import 'package:nullability/nullability.dart';
import 'package:search_backend/search_backend.dart';
class PageMetadataChips extends HookWidget {
final PageMetadata metadata;
const PageMetadataChips({super.key, required this.metadata});
@override
Widget build(BuildContext context) {
final chips = useMemoized(() {
final chips = <Widget>[];
final parsedDate = metadata.date.mapNotNull(DateTime.tryParse);
if (parsedDate != null) {
chips.add(
Chip(
avatar: const Icon(Icons.calendar_month),
label: Text(DateFormat.yMMMd().format(parsedDate)),
),
);
}
if (metadata.sitename case final String sitename
when sitename.isNotEmpty) {
chips.add(
Chip(avatar: const Icon(MdiIcons.domain), label: Text(sitename)),
);
}
if (metadata.author case final String author when author.isNotEmpty) {
chips.add(Chip(avatar: const Icon(Icons.person), label: Text(author)));
}
if (metadata.license case final String license when license.isNotEmpty) {
chips.add(
Chip(avatar: const Icon(MdiIcons.license), label: Text(license)),
);
}
return chips;
});
if (chips.isEmpty) {
return const SizedBox.shrink();
}
return Wrap(spacing: 8, runSpacing: 8, children: chips);
}
}
@@ -0,0 +1,187 @@
/*
* 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/presentation/hooks/on_initialization.dart';
class RouteThroughTorToggle extends HookConsumerWidget {
const RouteThroughTorToggle({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<bool> ensureTorBootstrap() async {
// Sync the latest native status into the stream first so subsequent
// listeners (toggle spinner, progress bar, search submit) don't see a
// stale `null`/`AsyncLoading` state on first build.
final status = await ref
.read(torProxyServiceProvider.notifier)
.requestSync();
if (status.isRunning) return false;
await ref.read(startProxyControllerProvider.notifier).startProxy();
return true;
}
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final routeThroughTor = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.routeThroughTor),
);
final torStatus = ref.watch(torProxyServiceProvider).value;
final activePort = ref.watch(searchProxyPortProvider);
final bootstrapProgress = torStatus?.bootstrapProgress ?? 0;
final showSpinner =
routeThroughTor && (activePort == null || bootstrapProgress < 100);
// Push the latest native status into the stream on first build so the
// bar reflects real progress even when the user lands on the search
// screen with Tor already mid-bootstrap (otherwise the AsyncLoading
// state lingers and the bar appears to spin forever at zero).
useOnInitialization(() async {
await ref.read(torProxyServiceProvider.notifier).requestSync();
});
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () async {
final wasTorEnabled = ref
.read(webSearchSettingsControllerProvider)
.routeThroughTor;
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRouteThroughTor(!wasTorEnabled);
if (!wasTorEnabled) {
await ensureTorBootstrap();
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: routeThroughTor
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showSpinner)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: routeThroughTor
? colorScheme.onPrimaryContainer
: colorScheme.primary,
),
)
else
Badge(
isLabelVisible: torStatus?.isRunning == true,
backgroundColor: AppColors.of(context).torActiveGreen,
child: Icon(
routeThroughTor
? Icons.shield_rounded
: Icons.shield_outlined,
color: routeThroughTor
? colorScheme.onPrimaryContainer
: colorScheme.primary,
size: 18,
),
),
const SizedBox(width: 6),
Text(
routeThroughTor ? 'Tor on' : 'Tor off',
style: textTheme.labelLarge?.copyWith(
color: routeThroughTor
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
/// Slim linear progress bar shown directly underneath the search-screen Tor
/// toggle while Tor is bootstrapping. Mirrors the bar on the Tor settings
/// screen so users get the same visual feedback regardless of where they
/// turned Tor on. Returns a zero-height widget when not relevant.
class WebSearchTorBootstrapProgress extends HookConsumerWidget {
const WebSearchTorBootstrapProgress({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final routeThroughTor = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.routeThroughTor),
);
if (!routeThroughTor) return const SizedBox.shrink();
// Push the latest native status into the stream on first build so the
// bar reflects real progress even when the user lands on the search
// screen with Tor already mid-bootstrap (otherwise the AsyncLoading
// state lingers and the bar appears to spin forever at zero).
useOnInitialization(() async {
await ref.read(torProxyServiceProvider.notifier).requestSync();
});
final torAsync = ref.watch(torProxyServiceProvider);
final status = torAsync.value;
final isRunning = status?.isRunning ?? false;
final bootstrapProgress = status?.bootstrapProgress ?? 0;
// Hide once Tor is fully running and bootstrapped — otherwise, mirror
// the Tor settings screen and always show a determinate bar (value
// anchored to the live bootstrap progress, never indeterminate, so the
// user can actually track the percentage).
if (isRunning && bootstrapProgress >= 100) return const SizedBox.shrink();
final appColors = AppColors.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
minHeight: 3,
backgroundColor: appColors.torBackgroundGrey,
color: appColors.torActiveGreen,
value: bootstrapProgress / 100,
),
),
);
}
}
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
class WebSearchField extends StatelessWidget {
final TextEditingController controller;
final bool enabled;
final Future<void> Function(String query) onSubmitted;
final VoidCallback onClear;
const WebSearchField({
super.key,
required this.controller,
required this.enabled,
required this.onSubmitted,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 60,
padding: const EdgeInsets.only(left: 20, right: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(30),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Row(
children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
minLines: 1,
maxLines: 5,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.search,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Search the web...',
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
isDense: true,
),
onSubmitted: enabled ? onSubmitted : null,
),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, child) {
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
tooltip: 'Clear',
onPressed: enabled ? onClear : null,
icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
);
},
),
const SizedBox(width: 4),
FilledButton(
onPressed: enabled ? () => onSubmitted(controller.text) : null,
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Search'),
),
],
),
);
}
}
@@ -0,0 +1,416 @@
/*
* 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/web_search/data/locale_options.dart';
class _FilterPill extends StatelessWidget {
final IconData icon;
final String label;
final bool isHighlighted;
final VoidCallback onTap;
const _FilterPill({
required this.icon,
required this.label,
required this.isHighlighted,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final fg = isHighlighted
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant;
return Material(
color: isHighlighted
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isHighlighted ? fg : colorScheme.primary,
size: 18,
),
const SizedBox(width: 6),
Text(
label,
style: textTheme.labelLarge?.copyWith(
color: fg,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down_rounded, size: 18, color: fg),
],
),
),
),
);
}
}
class _MenuRow extends StatelessWidget {
final String label;
final String? subtitle;
final bool isSelected;
final bool isHighlighted;
const _MenuRow({
required this.label,
this.subtitle,
required this.isSelected,
this.isHighlighted = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final labelColor = isHighlighted ? colorScheme.primary : null;
return Row(
children: [
Expanded(
child: subtitle != null
? RichText(
text: TextSpan(
children: [
TextSpan(
text: label,
style: textTheme.bodyMedium?.copyWith(
color: labelColor,
fontWeight: isSelected || isHighlighted
? FontWeight.bold
: null,
),
),
TextSpan(
text: ' $subtitle',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
)
: Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: labelColor,
fontWeight: isSelected || isHighlighted
? FontWeight.bold
: null,
),
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_rounded,
size: 18,
color: colorScheme.primary,
),
),
],
);
}
}
class LanguageSelector extends ConsumerWidget {
const LanguageSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = Localizations.localeOf(context);
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.language),
);
final isHighlighted = selected != null && selected != locale.languageCode;
final selectedOption = findLanguage(selected);
final label = selected == null
? 'Auto'
: (selectedOption?.name ?? selected);
final defaultOption = findLanguage(locale.languageCode);
final others = [
for (final l in supportedLanguages)
if (l.code != defaultOption?.code) l,
]..sort((a, b) => a.name.compareTo(b.name));
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.translate_rounded,
label: label,
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setLanguage(null);
},
child: _MenuRow(
label: 'Auto (device default)',
subtitle: defaultOption?.code ?? locale.languageCode,
isSelected: selected == null,
),
),
const Divider(),
for (final option in others)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setLanguage(option.code);
},
child: _MenuRow(
label: option.name,
subtitle: option.code,
isSelected: selected == option.code,
),
),
],
);
}
}
class CountrySelector extends ConsumerWidget {
const CountrySelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = Localizations.localeOf(context);
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.region),
);
final isHighlighted = selected != null && selected != locale.countryCode;
final selectedOption = findCountry(selected);
final label = selected == null ? 'Any' : (selectedOption?.name ?? selected);
final defaultOption = findCountry(locale.countryCode);
final others = [
for (final c in supportedCountries)
if (c.code != defaultOption?.code) c,
]..sort((a, b) => a.name.compareTo(b.name));
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.public_rounded,
label: label,
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRegion(null);
},
child: _MenuRow(label: 'Any region', isSelected: selected == null),
),
if (defaultOption != null) ...[
const Divider(),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRegion(defaultOption.code);
},
child: _MenuRow(
label: '${defaultOption.name} (device)',
subtitle: defaultOption.code,
isSelected: selected == defaultOption.code,
),
),
],
const Divider(),
for (final option in others)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setRegion(option.code);
},
child: _MenuRow(
label: option.name,
subtitle: option.code,
isSelected: selected == option.code,
),
),
],
);
}
}
class SafeSearchSelector extends ConsumerWidget {
const SafeSearchSelector({super.key});
String _label(SafeSearch? value) => switch (value) {
null => 'Safe: default',
SafeSearch.none => 'Safe: off',
SafeSearch.moderate => 'Safe: moderate',
SafeSearch.strict => 'Safe: strict',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.safeSearch),
);
final isHighlighted = selected != null;
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.shield_moon_outlined,
label: _label(selected),
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(null);
},
child: _MenuRow(
label: 'Default (moderate)',
isSelected: selected == null,
),
),
const Divider(),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(SafeSearch.none);
},
child: _MenuRow(
label: 'Off',
isSelected: selected == SafeSearch.none,
isHighlighted: true,
),
),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(SafeSearch.moderate);
},
child: _MenuRow(
label: 'Moderate',
isSelected: selected == SafeSearch.moderate,
),
),
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSafeSearch(SafeSearch.strict);
},
child: _MenuRow(
label: 'Strict',
isSelected: selected == SafeSearch.strict,
isHighlighted: true,
),
),
],
);
}
}
class FreshnessSelector extends ConsumerWidget {
const FreshnessSelector({super.key});
String _label(TimeRange? value) => switch (value) {
null => 'Any time',
TimeRange.day => 'Past day',
TimeRange.week => 'Past week',
TimeRange.month => 'Past month',
TimeRange.year => 'Past year',
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.timeRange),
);
final isHighlighted = selected != null;
return MenuAnchor(
builder: (context, controller, _) => _FilterPill(
icon: Icons.schedule_rounded,
label: _label(selected),
isHighlighted: isHighlighted,
onTap: () => controller.isOpen ? controller.close() : controller.open(),
),
menuChildren: [
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setTimeRange(null);
},
child: _MenuRow(label: 'Any time', isSelected: selected == null),
),
const Divider(),
for (final value in TimeRange.values)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setTimeRange(value);
},
child: _MenuRow(
label: _label(value),
isSelected: selected == value,
),
),
],
);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
class SearchModeSelector extends ConsumerWidget {
const SearchModeSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final searchMode = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.searchMode),
);
return MenuAnchor(
builder: (context, controller, _) => InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => controller.isOpen ? controller.close() : controller.open(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_iconFor(searchMode), color: colorScheme.primary, size: 18),
const SizedBox(width: 6),
Text(
_labelFor(searchMode),
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
menuChildren: [
for (final mode in SearchMode.values)
MenuItemButton(
onPressed: () {
ref
.read(webSearchSettingsControllerProvider.notifier)
.setSearchMode(mode);
},
leadingIcon: Icon(
_iconFor(mode),
size: 20,
color: mode == searchMode
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
trailingIcon: mode == searchMode
? Icon(
Icons.check_rounded,
size: 18,
color: colorScheme.primary,
)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_labelFor(mode),
style: textTheme.bodyMedium?.copyWith(
fontWeight: mode == searchMode
? FontWeight.bold
: FontWeight.normal,
),
),
Text(
_descriptionFor(mode),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
static IconData _iconFor(SearchMode mode) => switch (mode) {
SearchMode.general => Icons.public,
SearchMode.independentWeb => Icons.volunteer_activism,
SearchMode.smallWeb => Icons.explore,
};
static String _labelFor(SearchMode mode) => switch (mode) {
SearchMode.general => 'General',
SearchMode.independentWeb => 'Independent Web',
SearchMode.smallWeb => 'Small Web',
};
static String _descriptionFor(SearchMode mode) => switch (mode) {
SearchMode.general => 'Balanced results across the open web',
SearchMode.independentWeb => 'Favor smaller and less corporate sources',
SearchMode.smallWeb => 'Independent, personal & niche sites',
};
}
@@ -0,0 +1,527 @@
import 'dart:typed_data';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:nullability/nullability.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart';
import 'package:weblibre/features/web_search/domain/entities/fetch_method.dart';
import 'package:weblibre/features/web_search/presentation/widgets/search_result_metadata_chips.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class WebSearchResultCard extends ConsumerWidget {
final CompactSearchResult result;
final Future<void> Function(Uri url) onOpen;
final Future<void> Function(Uri url) onFetch;
final Future<void> Function(Uri url) onPreview;
final Future<void> Function(CapturedPageState captured) onOpenCapture;
const WebSearchResultCard({
super.key,
required this.result,
required this.onOpen,
required this.onFetch,
required this.onPreview,
required this.onOpenCapture,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = (result.imgSrc?.isNotEmpty ?? false)
? result.imgSrc
: (result.thumbnail?.isNotEmpty ?? false)
? result.thumbnail
: null;
final imageBytes = imageUrl.mapNotNull(
(imageUrl) => ref.watch(
metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]),
),
);
final document = ref.watch(
metaSearchControllerProvider.select((s) => s.documentsByUrl[result.url]),
);
final queryLanguage = ref.watch(
webSearchSettingsControllerProvider.select((s) => s.language),
);
return Card(
color: colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: () => onOpen(result.url),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_UrlRow(result: result),
const SizedBox(height: 16),
_ContentRow(
result: result,
imageBytes: imageBytes,
colorScheme: colorScheme,
textTheme: textTheme,
),
if ((result.metadata ?? const []).isNotEmpty ||
document != null) ...[
const SizedBox(height: 12),
SearchResultMetadataChips(
metadata: result.metadata ?? const [],
pageMetadata: document?.metadata,
queryLanguage: queryLanguage,
),
],
if (result.metadata case final metadata? when metadata.isNotEmpty)
SearchResultMetadataExpandable(metadata: metadata),
const SizedBox(height: 16),
_FetchFooter(
url: result.url,
onFetch: onFetch,
onPreview: onPreview,
onOpenCapture: onOpenCapture,
),
],
),
),
),
);
}
}
class _UrlRow extends StatelessWidget {
final CompactSearchResult result;
const _UrlRow({required this.result});
@override
Widget build(BuildContext context) {
return UriBreadcrumb(
uri: result.url,
icon: UrlIcon([result.url], iconSize: 16, cacheOnly: true),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
class _ContentRow extends StatelessWidget {
final CompactSearchResult result;
final Uint8List? imageBytes;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _ContentRow({
required this.result,
required this.imageBytes,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageBytes != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
imageBytes!,
width: 88,
height: 88,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.title,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
height: 1.3,
),
),
if (result.publishedDate case final String date
when date.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_formatDate(date),
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (result.content case final String content
when content.trim().isNotEmpty) ...[
const SizedBox(height: 6),
_ExpandableDescription(
text: content.trim(),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
],
),
),
],
);
}
String _formatDate(String date) {
final parsed = DateTime.tryParse(date);
if (parsed != null) {
return DateFormat.yMMMd().format(parsed);
}
return date;
}
}
/// Per-URL footer state, batched into a single select() so the footer only
/// rebuilds when something relevant to *this* URL changes.
///
/// `capturing`/`captures` are exposed for rendering, but they are NOT used in
/// [hashParameters] — Dart's built-in `Set`/`Map` compare by identity, so
/// including them would defeat the dedupe (the controller produces fresh
/// instances on every state mutation). Equality is driven instead by scalar
/// signatures derived from those collections, which is enough to detect any
/// change a single result card cares about.
class _FooterState with FastEquatable {
final bool hasOpenSession;
final bool isFetching;
final bool isFetched;
final String? fetchError;
final Set<FetchMethodChoice> capturing;
final Map<FetchMethodChoice, CapturedPageState> captures;
final int _capturingSignature;
final int _capturesSignature;
_FooterState({
required this.hasOpenSession,
required this.isFetching,
required this.isFetched,
required this.fetchError,
required this.capturing,
required this.captures,
}) : _capturingSignature = _hashCapturing(capturing),
_capturesSignature = _hashCaptures(captures);
static int _hashCapturing(Set<FetchMethodChoice> set) {
// Order-independent hash so we don't depend on Set iteration order.
var h = 0;
for (final choice in set) {
h ^= choice.index;
}
return h;
}
static int _hashCaptures(Map<FetchMethodChoice, CapturedPageState> map) {
var h = 0;
for (final entry in map.entries) {
// status + errorMessage + localPath cover everything the footer renders
// about a capture; any change that affects the rendered chip flips one
// of these fields.
final v = Object.hash(
entry.key.index,
entry.value.status.index,
entry.value.errorMessage,
entry.value.localPath,
entry.value.captureId,
entry.value.downloadToken,
);
h ^= v;
}
return h;
}
@override
List<Object?> get hashParameters => [
hasOpenSession,
isFetching,
isFetched,
fetchError,
_capturingSignature,
_capturesSignature,
];
}
class _FetchFooter extends ConsumerWidget {
final Uri url;
final Future<void> Function(Uri url) onFetch;
final Future<void> Function(Uri url) onPreview;
final Future<void> Function(CapturedPageState captured) onOpenCapture;
const _FetchFooter({
required this.url,
required this.onFetch,
required this.onPreview,
required this.onOpenCapture,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final footerState = ref.watch(
metaSearchControllerProvider.select(
(s) => _FooterState(
hasOpenSession: s.hasOpenSession,
isFetching: s.fetchingUrls.contains(url),
isFetched: s.documentsByUrl.containsKey(url),
fetchError: s.fetchErrorByUrl[url],
capturing: s.capturingByUrl[url] ?? const {},
captures: s.capturedPagesByUrl[url] ?? const {},
),
),
);
final readyChips = <Widget>[];
final busyChips = <Widget>[];
final errorChips = <Widget>[];
for (final choice in FetchMethodChoice.values) {
if (choice == FetchMethodChoice.trafilatura) {
if (footerState.isFetched) {
readyChips.add(
_CaptureChip(choice: choice, onTap: () => onPreview(url)),
);
} else if (footerState.fetchError case final String err) {
errorChips.add(
_ErrorChip(
choice: choice,
errorMessage: err,
canRetry: footerState.hasOpenSession,
onRetry: () => ref
.read(metaSearchControllerProvider.notifier)
.fetchPage(url),
),
);
}
continue;
}
final captured = footerState.captures[choice];
if (captured != null && captured.status == CapturedPageStatus.ready) {
readyChips.add(
_CaptureChip(choice: choice, onTap: () => onOpenCapture(captured)),
);
} else if (captured != null &&
captured.status == CapturedPageStatus.downloadFailed) {
// Distinguish "download failed" (we have a captureId/downloadToken
// and can retry just the artifact download — no new server work)
// from "capture failed on server" (no captureId; would need a fresh
// capture command, which costs another upstream render).
final canRetryDownload =
captured.captureId != null && captured.downloadToken != null;
errorChips.add(
_ErrorChip(
choice: choice,
errorMessage:
captured.errorMessage ?? 'Capture failed for unknown reason.',
canRetry: canRetryDownload && footerState.hasOpenSession,
onRetry: () => ref
.read(metaSearchControllerProvider.notifier)
.retryCaptureDownload(url, choice),
),
);
} else if (_isMethodBusy(footerState, choice)) {
busyChips.add(_BusyChip(choice: choice));
}
}
final chips = [...readyChips, ...busyChips, ...errorChips];
return Row(
children: [
Expanded(
child: chips.isEmpty
? const SizedBox.shrink()
: Wrap(spacing: 6, runSpacing: 6, children: chips),
),
// Once the WebSocket session has closed, no further fetch/capture
// commands can be issued — hide the button rather than have it
// surface a generic "session is no longer available" error on every
// tap. The chips above remain interactive (read-only).
if (footerState.hasOpenSession) ...[
const SizedBox(width: 8),
FilledButton.tonalIcon(
onPressed: () => onFetch(url),
icon: const Icon(Icons.download_rounded, size: 18),
label: const Text('Fetch'),
style: FilledButton.styleFrom(elevation: 0),
),
],
],
);
}
bool _isMethodBusy(_FooterState s, FetchMethodChoice method) {
if (method == FetchMethodChoice.trafilatura) {
return s.isFetching;
}
if (s.capturing.contains(method)) {
return true;
}
final captured = s.captures[method];
return captured != null &&
(captured.status == CapturedPageStatus.capturing ||
captured.status == CapturedPageStatus.downloading);
}
}
class _CaptureChip extends StatelessWidget {
final FetchMethodChoice choice;
final Future<void> Function() onTap;
const _CaptureChip({required this.choice, required this.onTap});
@override
Widget build(BuildContext context) {
return ActionChip(
avatar: Icon(choice.icon, size: 16),
label: Text(choice.shortLabel),
visualDensity: VisualDensity.compact,
onPressed: () => onTap(),
);
}
}
/// Failed-fetch / failed-capture chip. Always rendered (red, with an error
/// outline icon) so the user can see *which* method failed; tapping pops a
/// dialog with the verbatim server message and an optional retry action.
class _ErrorChip extends StatelessWidget {
final FetchMethodChoice choice;
final String errorMessage;
final bool canRetry;
final Future<void> Function() onRetry;
const _ErrorChip({
required this.choice,
required this.errorMessage,
required this.canRetry,
required this.onRetry,
});
Future<void> _showDetail(BuildContext context) async {
final retry = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(child: Text('${choice.title} failed')),
],
),
content: SingleChildScrollView(child: Text(errorMessage)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Close'),
),
if (canRetry)
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Retry'),
),
],
);
},
);
if (retry == true) {
await onRetry();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ActionChip(
avatar: Icon(Icons.error_outline, size: 16, color: colorScheme.error),
label: Text(choice.shortLabel),
visualDensity: VisualDensity.compact,
side: BorderSide(color: colorScheme.error),
labelStyle: TextStyle(color: colorScheme.error),
onPressed: () => _showDetail(context),
);
}
}
class _BusyChip extends StatelessWidget {
final FetchMethodChoice choice;
const _BusyChip({required this.choice});
@override
Widget build(BuildContext context) {
return Chip(
avatar: const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
label: Text(choice.shortLabel),
visualDensity: VisualDensity.compact,
);
}
}
class _ExpandableDescription extends HookWidget {
static const _collapsedMaxLines = 4;
final String text;
final TextStyle? style;
const _ExpandableDescription({required this.text, required this.style});
@override
Widget build(BuildContext context) {
final expanded = useState(false);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () {
expanded.value = !expanded.value;
},
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Text(
text,
maxLines: expanded.value ? null : _collapsedMaxLines,
overflow: expanded.value ? TextOverflow.clip : TextOverflow.ellipsis,
style: style,
),
),
);
}
}
@@ -0,0 +1,407 @@
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:intl/locale.dart' as intl;
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/domain/repositories/locale_resolver.dart';
const _expandableKeys = {'snippet', 'review', 'question'};
const _keyOrder = <String>[
// Badges
'type',
'subtype',
'access',
// Time / freshness
'released',
'duration',
'time',
'hours',
// Quality / popularity
'rating',
'answers',
'stars',
'forks',
// Identity
'author',
'publisher',
'organization',
'sitename',
'forum',
// Commerce
'price',
'price_range',
// Content descriptors
'genre',
'cuisine',
'categories',
'pages',
'servings',
'calories',
'version',
'distance',
// Meta
'language',
'license',
'pagetype',
];
const _hiddenKeys = {
// The publisher-declared article date duplicates the inline `publishedDate`
// shown under the title. Keep only the inline version.
'date',
'score',
'gravity',
'quality',
'phrases',
'size',
'format',
'results_from_domain',
'more_from_domain',
'content_type',
'tags',
};
// Built once at import time so the sort comparator doesn't rebuild it on
// every widget rebuild.
final Map<String, int> _keyOrderIndex = {
for (var i = 0; i < _keyOrder.length; i++) _keyOrder[i]: i,
};
IconData _iconFor(String key) {
switch (key) {
case 'type':
return Icons.label_outline;
case 'subtype':
return Icons.category_outlined;
case 'rating':
return Icons.star_outline;
case 'language':
return Icons.language;
case 'author':
return Icons.person_outline;
case 'publisher':
case 'organization':
case 'sitename':
return MdiIcons.domain;
case 'forum':
return MdiIcons.forum;
case 'answers':
return Icons.question_answer_outlined;
case 'price':
case 'price_range':
return MdiIcons.currencyUsd;
case 'access':
return Icons.lock_outline;
case 'duration':
case 'time':
case 'hours':
return Icons.schedule;
case 'pages':
return MdiIcons.bookOpenPageVariantOutline;
// `date` is in `_hiddenKeys`, so only `released` reaches this branch in
// practice — keep the case for safety should `date` ever be un-hidden.
case 'released':
case 'date':
return Icons.calendar_month;
case 'genre':
case 'cuisine':
case 'categories':
return Icons.local_offer_outlined;
case 'servings':
return MdiIcons.silverwareForkKnife;
case 'calories':
return MdiIcons.fire;
case 'stars':
return Icons.star_border;
case 'forks':
return MdiIcons.sourceFork;
case 'version':
return MdiIcons.tagOutline;
case 'distance':
return Icons.place_outlined;
case 'license':
return MdiIcons.license;
case 'pagetype':
return Icons.article_outlined;
default:
return Icons.info_outline;
}
}
String? _formatValue(String key, String value) {
switch (key) {
case 'released':
final parsed = DateTime.tryParse(value);
if (parsed != null) return DateFormat.yMMMd().format(parsed);
return value;
default:
return value;
}
}
/// Compare the primary subtag of two BCP 47-ish language strings, ignoring
/// region and case. `null`/empty `queryTag` means "no preference set", so
/// the result language is always shown.
bool _languageMatchesQuery(String resultLanguage, String? queryTag) {
if (queryTag == null || queryTag.isEmpty) return false;
final result = resultLanguage
.trim()
.toLowerCase()
.split(RegExp(r'[-_]'))
.first;
final query = queryTag.trim().toLowerCase().split(RegExp(r'[-_]')).first;
if (result.isEmpty || query.isEmpty) return false;
return result == query;
}
List<MetadataItem> _metadataFromPage(PageMetadata? pageMetadata) {
if (pageMetadata == null) return const [];
final items = <MetadataItem>[];
if (pageMetadata.date case final String date when date.isNotEmpty) {
items.add(MetadataItem(key: 'released', value: date));
}
if (pageMetadata.sitename case final String sitename
when sitename.isNotEmpty) {
items.add(MetadataItem(key: 'sitename', value: sitename));
}
if (pageMetadata.author case final String author when author.isNotEmpty) {
items.add(MetadataItem(key: 'author', value: author));
}
if (pageMetadata.language case final String language
when language.isNotEmpty) {
items.add(MetadataItem(key: 'language', value: language));
}
if (pageMetadata.license case final String license when license.isNotEmpty) {
items.add(MetadataItem(key: 'license', value: license));
}
if (pageMetadata.pagetype case final String pagetype
when pagetype.isNotEmpty) {
items.add(MetadataItem(key: 'pagetype', value: pagetype));
}
return items;
}
class SearchResultMetadataChips extends StatelessWidget {
final List<MetadataItem> metadata;
final PageMetadata? pageMetadata;
/// ISO 639-1 language code of the query (e.g. `en`, `de`). When the
/// result's `language` metadata matches by primary subtag, the language
/// chip is suppressed as redundant.
final String? queryLanguage;
const SearchResultMetadataChips({
super.key,
required this.metadata,
this.pageMetadata,
this.queryLanguage,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final seen = <String>{};
final merged = <_ResolvedMetadataItem>[];
// Page metadata items first so they take priority on deduplication.
for (final item in _metadataFromPage(pageMetadata)) {
if (_expandableKeys.contains(item.key)) continue;
if (_hiddenKeys.contains(item.key)) continue;
if (item.value.trim().isEmpty) continue;
if (item.key == 'language' &&
_languageMatchesQuery(item.value, queryLanguage)) {
continue;
}
if (!seen.add(item.key)) continue;
merged.add(
_ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'),
);
}
for (final item in metadata) {
if (_expandableKeys.contains(item.key)) continue;
if (_hiddenKeys.contains(item.key)) continue;
if (item.value.trim().isEmpty) continue;
if (item.key == 'language' &&
_languageMatchesQuery(item.value, queryLanguage)) {
continue;
}
// Skip if page metadata already provided this key (preview takes priority).
if (!seen.add(item.key)) continue;
merged.add(
_ResolvedMetadataItem(item: item, isLanguage: item.key == 'language'),
);
}
if (merged.isEmpty) return const SizedBox.shrink();
merged.sort((a, b) {
final ai = _keyOrderIndex[a.item.key] ?? _keyOrder.length;
final bi = _keyOrderIndex[b.item.key] ?? _keyOrder.length;
return ai.compareTo(bi);
});
return FadingScroll(
fadingSize: 15,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < merged.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
// Decorative — these chips don't filter or navigate. Using a
// plain Chip (instead of an OutlinedButton with an empty
// onPressed) avoids the misleading tap ripple.
Chip(
avatar: Icon(
_iconFor(merged[i].item.key),
size: 16,
color: colorScheme.onSurfaceVariant,
),
label: merged[i].isLanguage
? _ResolvedLanguageLabel(
languageTag: merged[i].item.value,
)
: Text(
_formatValue(
merged[i].item.key,
merged[i].item.value,
) ??
merged[i].item.value,
),
side: BorderSide(color: colorScheme.outlineVariant),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
backgroundColor: Colors.transparent,
visualDensity: VisualDensity.compact,
),
],
],
),
);
},
);
}
}
class _ResolvedMetadataItem {
final MetadataItem item;
final bool isLanguage;
const _ResolvedMetadataItem({required this.item, required this.isLanguage});
}
class _ResolvedLanguageLabel extends ConsumerWidget {
final String languageTag;
const _ResolvedLanguageLabel({required this.languageTag});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = intl.Locale.tryParse(languageTag);
if (locale == null) return Text(languageTag);
final resolved = ref.watch(resolveLocaleProvider(locale));
return Text(
resolved.maybeWhen(
data: (data) => data.languageName,
orElse: () => languageTag,
),
);
}
}
class SearchResultMetadataExpandable extends StatelessWidget {
final List<MetadataItem> metadata;
const SearchResultMetadataExpandable({super.key, required this.metadata});
@override
Widget build(BuildContext context) {
final entries = metadata
.where((item) => _expandableKeys.contains(item.key))
.where((item) => item.value.trim().isNotEmpty)
.toList();
if (entries.isEmpty) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final hasSnippets = entries.any((e) => e.key == 'snippet');
final hasReview = entries.any((e) => e.key == 'review');
final hasQuestion = entries.any((e) => e.key == 'question');
final parts = [
if (hasQuestion) 'question',
if (hasSnippets) 'snippets',
if (hasReview) 'review',
];
final label = parts.isEmpty ? 'More' : 'Show ${parts.join(' & ')}';
return Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
label,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
children: [
for (final item in entries) ...[
if (item != entries.first) const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text.rich(
TextSpan(
children: [
if (item.key == 'question')
TextSpan(
text: 'Q: ',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
height: 1.5,
),
),
TextSpan(
text: item.value,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontStyle: item.key == 'question'
? FontStyle.italic
: null,
),
),
],
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,398 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:search_backend/search_backend.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class WebSearchInfoboxCard extends HookConsumerWidget {
final CompactInfobox info;
final Future<void> Function(Uri url) onOpen;
/// When non-null the card uses this externally-controlled expansion state
/// (e.g. shared across a carousel). When null, the card manages its own
/// state with a default of expanded.
final bool? expandedOverride;
final VoidCallback? onToggleExpanded;
const WebSearchInfoboxCard({
super.key,
required this.info,
required this.onOpen,
this.expandedOverride,
this.onToggleExpanded,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = info.imgSrc;
final imageBytes = (imageUrl == null || imageUrl.isEmpty)
? null
: ref.watch(
metaSearchControllerProvider.select((s) => s.imagesByUrl[imageUrl]),
);
final attributes = info.attributes ?? const <InfoboxAttribute>[];
final urls = info.urls ?? const <InfoboxUrl>[];
final heading = _heading(info);
final localExpanded = useState(true);
final isExpanded = expandedOverride ?? localExpanded.value;
void toggle() {
final external = onToggleExpanded;
if (external != null) {
external();
} else {
localExpanded.value = !localExpanded.value;
}
}
return Card(
color: colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: toggle,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (heading.isNotEmpty)
Text(
heading,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
if (info.url case final Uri sourceUrl) ...[
const SizedBox(height: 6),
UriBreadcrumb(
uri: sourceUrl,
icon: UrlIcon(
[sourceUrl],
iconSize: 16,
cacheOnly: true,
),
style: textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.expand_more,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
if (isExpanded)
_Body(
info: info,
imageBytes: imageBytes,
attributes: attributes,
urls: urls,
onOpen: onOpen,
colorScheme: colorScheme,
textTheme: textTheme,
),
],
),
);
}
String _heading(CompactInfobox info) {
final title = info.title?.trim();
if (title != null && title.isNotEmpty) return title;
return info.infobox.trim();
}
}
class _Body extends StatelessWidget {
final CompactInfobox info;
final Uint8List? imageBytes;
final List<InfoboxAttribute> attributes;
final List<InfoboxUrl> urls;
final Future<void> Function(Uri url) onOpen;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _Body({
required this.info,
required this.imageBytes,
required this.attributes,
required this.urls,
required this.onOpen,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageBytes != null) ...[
Align(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
imageBytes!,
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
),
),
const SizedBox(height: 16),
],
if (info.content case final String content
when content.trim().isNotEmpty) ...[
Text(
content.trim(),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 12),
],
if (info.source.isNotEmpty)
Text(
'Source: ${info.source}',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (attributes.isNotEmpty)
_Factsheet(
attributes: attributes,
colorScheme: colorScheme,
textTheme: textTheme,
),
if (urls.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final urlObj in urls) ...[
OutlinedButton.icon(
onPressed: () => onOpen(urlObj.url),
icon: Icon(_iconForLink(urlObj.title), size: 16),
label: Text(urlObj.title),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
side: BorderSide(color: colorScheme.outlineVariant),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
const SizedBox(width: 8),
],
],
),
),
)
else
const SizedBox(height: 16),
],
);
}
IconData _iconForLink(String title) {
final lower = title.toLowerCase();
if (lower.contains('wikipedia') || lower.contains('wiki')) {
return Icons.article_outlined;
}
if (lower.contains('reddit')) return Icons.forum_outlined;
if (lower.contains('facebook')) return Icons.facebook_outlined;
if (lower.contains('youtube') || lower.contains('video')) {
return Icons.play_circle_outline;
}
if (lower.contains('twitter') || lower.contains('x.com')) {
return Icons.alternate_email;
}
if (lower.contains('instagram')) return Icons.photo_camera_outlined;
if (lower.contains('github')) return Icons.code;
if (lower.contains('mastodon')) return Icons.public;
return Icons.link;
}
}
class _Factsheet extends StatelessWidget {
final List<InfoboxAttribute> attributes;
final ColorScheme colorScheme;
final TextTheme textTheme;
const _Factsheet({
required this.attributes,
required this.colorScheme,
required this.textTheme,
});
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
title: Text(
'Factsheet',
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final attr in attributes)
if (attr.value case final String value when value.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: RichText(
text: TextSpan(
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
children: [
TextSpan(
text:
'${attr.label.replaceAll(RegExp(r':+\s*$'), '')}: ',
style: TextStyle(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
TextSpan(text: value),
],
),
),
),
],
),
);
}
}
class WebSearchInfoboxCarousel extends HookConsumerWidget {
final List<CompactInfobox> infos;
final Future<void> Function(Uri url) onOpen;
/// Upper bound for the carousel viewport. The PageView gets exactly this
/// height; if a card is taller, the inner [SingleChildScrollView] handles
/// the overflow. Picked large enough to fit a typical Wikipedia-style
/// infobox without scrolling, small enough not to dominate the screen.
static const _maxCardHeight = 520.0;
const WebSearchInfoboxCarousel({
super.key,
required this.infos,
required this.onOpen,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final controller = usePageController();
final currentPage = useState(0);
final expanded = useState(true);
useEffect(() {
void listener() {
final page = controller.page?.round() ?? 0;
if (page != currentPage.value) {
currentPage.value = page;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [controller]);
// Earlier revisions measured each page's real height in a post-frame
// callback and animated the carousel to match. With SizeChangedLayout
// notifications + post-frame setState, this created a measurement
// feedback loop that was vulnerable to floating-point jitter and
// sometimes spent the whole expand/collapse animation re-measuring.
// Using a fixed maximum + per-page scrolling sidesteps the loop and is
// measurably cheaper.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: _maxCardHeight,
child: PageView.builder(
controller: controller,
itemCount: infos.length,
itemBuilder: (context, index) {
return SingleChildScrollView(
child: WebSearchInfoboxCard(
info: infos[index],
onOpen: onOpen,
expandedOverride: expanded.value,
onToggleExpanded: () => expanded.value = !expanded.value,
),
);
},
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < infos.length; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: currentPage.value == i ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: currentPage.value == i
? colorScheme.primary
: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(3),
),
),
],
),
],
);
}
}
@@ -0,0 +1,305 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/account/data/supabase_config.dart';
import 'package:weblibre/features/search_credits/domain/repositories/search_credits_repository.dart';
import 'package:weblibre/features/search_credits/domain/repositories/search_token_stash_repository.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
import 'package:weblibre/features/web_search/domain/entities/captured_page_state.dart';
import 'package:weblibre/features/web_search/presentation/dialogs/fetch_method_dialog.dart';
import 'package:weblibre/features/web_search/presentation/open_in_new_tab.dart';
import 'package:weblibre/features/web_search/presentation/screens/page_preview.dart';
import 'package:weblibre/features/web_search/presentation/widgets/search_result_card.dart';
import 'package:weblibre/features/web_search/presentation/widgets/web_search_infobox_card.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
/// Number of result cards from the bottom at which to prefetch the next
/// page. With a backend page size of 10, four cards of look-ahead means
/// the next page is requested when the user reaches result 6/10 — enough
/// runway for the round-trip on a typical mobile connection without
/// firing the request when it's still ambiguous whether the user will
/// actually scroll further.
const _loadMoreThreshold = 4;
/// Combined credits+tokens balance below which the small "low credits"
/// chip surfaces in the app bar. Picked so the user gets ample warning
/// (~one full page worth of token spend) before running out mid-session.
const _lowCreditWarningThreshold = 25;
/// Resolves [WebSearchOpenTarget] for the *next* result tap.
///
/// We resolve lazily (per-tap) instead of capturing once at section build
/// time so the user can change the tab-type or container selectors in the
/// search header *after* a search completes and have those choices honoured
/// on subsequent taps.
typedef WebSearchOpenTargetResolver = WebSearchOpenTarget Function();
class WebSearchResultsSection extends HookConsumerWidget {
final WebSearchOpenTargetResolver resolveOpenTarget;
const WebSearchResultsSection({super.key, required this.resolveOpenTarget});
@override
Widget build(BuildContext context, WidgetRef ref) {
useOnAppLifecycleStateChange((previous, current) async {
if (current == AppLifecycleState.resumed) {
await ref.read(searchCreditsRepositoryProvider.notifier).refresh();
}
});
Future<void> openUri(Uri uri) {
return ref
.read(webSearchTabOpenerProvider)
.open(context, ref, uri, target: resolveOpenTarget());
}
Future<void> showPreview(Uri uri) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
PagePreviewScreen(uri: uri, resolveOpenTarget: resolveOpenTarget),
),
);
}
Future<void> openCapture(CapturedPageState captured) async {
final captureId = captured.captureId;
if (captureId == null || captured.localPath == null) {
return;
}
await ref
.read(webSearchTabOpenerProvider)
.openCapture(
context,
ref,
captureId: captureId,
sourceUrl: captured.sourceUrl,
target: resolveOpenTarget(),
contentType: captured.contentType,
method: captured.method,
variant: captured.variant,
);
}
Future<void> onFetch(Uri uri) {
return showFetchMethodSheet(
context,
url: uri,
onPreview: showPreview,
onOpenCapture: openCapture,
);
}
// We re-render the whole results sliver on any controller state change
// because we need the full results/infos lists below. The `select` for
// the empty-error message is therefore subsumed by this watch.
final state = ref.watch(metaSearchControllerProvider);
if (state.status == WebSearchStatus.needsCredits) {
return const SliverToBoxAdapter(child: _NeedsCredits());
}
if (state.status == WebSearchStatus.error && state.results.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: FailureWidget(
title: 'Search failed',
exception: state.errorMessage,
),
),
);
}
if (state.status == WebSearchStatus.submitting && state.results.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Searching the web...'),
],
),
),
);
}
if (state.results.isNotEmpty || state.infos.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
sliver: SliverMainAxisGroup(
slivers: [
if (state.infos.length == 1)
SliverToBoxAdapter(
child: WebSearchInfoboxCard(
info: state.infos.first,
onOpen: openUri,
),
)
else if (state.infos.length > 1)
SliverToBoxAdapter(
child: WebSearchInfoboxCarousel(
infos: state.infos,
onOpen: openUri,
),
),
if (state.infos.isNotEmpty && state.results.isNotEmpty)
const SliverToBoxAdapter(child: SizedBox(height: 12)),
SliverList.separated(
itemCount: state.results.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
// Prefetch the next page once the user is within
// _loadMoreThreshold cards of the end. Scheduled in a
// post-frame callback because triggering state writes
// during build is not allowed; the controller no-ops if
// a load is already in flight, so re-scheduling on
// rebuilds is safe.
if (state.hasMore &&
!state.isLoadingMore &&
index >= state.results.length - _loadMoreThreshold) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(
ref
.read(metaSearchControllerProvider.notifier)
.loadNextPage(),
);
});
}
final result = state.results[index];
return WebSearchResultCard(
key: ValueKey(result.url),
result: result,
onOpen: openUri,
onFetch: onFetch,
onPreview: showPreview,
onOpenCapture: openCapture,
);
},
),
if (state.hasMore || state.isLoadingMore)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: state.isLoadingMore
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const SizedBox.shrink(),
),
),
),
],
),
);
}
if (state.query.isNotEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No results found for "${state.query}".',
textAlign: TextAlign.center,
),
),
);
}
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
}
class WebSearchStatusChip extends ConsumerWidget {
const WebSearchStatusChip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final creditsAsync = ref.watch(searchCreditsRepositoryProvider);
final stashAsync = ref.watch(searchTokenStashCountProvider);
if (!creditsAsync.hasValue || !stashAsync.hasValue) {
return const SizedBox.shrink();
}
final credits = creditsAsync.value!.availableCredits;
final stash = stashAsync.value!;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
if (credits + stash >= _lowCreditWarningThreshold) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => AccountSettingsRoute().push(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.stars_rounded, color: colorScheme.primary, size: 18),
const SizedBox(width: 6),
Text(
'$credits credits | $stash tokens',
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
);
}
}
class _NeedsCredits extends StatelessWidget {
const _NeedsCredits();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'No search credits or tokens are available for a new web search.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('Buy a search pack'),
onPressed: () async {
await launchUrl(
Uri.parse('${SupabaseConfig.accountWebUrl}?view=search-pack'),
mode: LaunchMode.inAppBrowserView,
);
},
),
],
),
);
}
}