Add Supa account and search changes
This commit is contained in:
+690
@@ -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,
|
||||
});
|
||||
}
|
||||
+472
@@ -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,
|
||||
};
|
||||
}
|
||||
+213
@@ -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),
|
||||
);
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user