Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:search_backend/search_backend.dart';
import 'package:search_client/search_client.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/features/search_credits/domain/providers.dart';
import 'package:weblibre/features/search_credits/domain/providers/proxy_client.dart';
part 'capture_artifact_downloader.g.dart';
class CaptureArtifactDownloadException implements Exception {
final String message;
final int? statusCode;
CaptureArtifactDownloadException(this.message, {this.statusCode});
@override
String toString() =>
'CaptureArtifactDownloadException: $message'
'${statusCode != null ? ' (status $statusCode)' : ''}';
}
class CaptureArtifactDownloader {
CaptureArtifactDownloader({
required BackendEndpoints endpoints,
required Directory storageDir,
http.Client? client,
}) : _endpoints = endpoints,
_storageDir = storageDir,
_client = client ?? http.Client();
final BackendEndpoints _endpoints;
final Directory _storageDir;
final http.Client _client;
/// Bound on the artifact download — covers connect + read combined. The
/// download path runs through the proxy client (which may be Tor), so a
/// stuck connection would otherwise leave the UI in `downloading` forever.
static const _downloadTimeout = Duration(seconds: 60);
Future<String> download(CaptureArtifactReceipt receipt) async {
await _storageDir.create(recursive: true);
final extension = captureFileExtension(receipt.contentType);
if (extension == null) {
// No on-disk extension we know how to serve back to Gecko later — the
// loopback capture server only probes a fixed set of extensions and an
// `.bin` artifact would be orphaned on disk. Surface the failure
// upstream instead of silently writing a file that can never load.
throw CaptureArtifactDownloadException(
'unsupported capture content type: ${receipt.contentType}',
);
}
final targetPath = p.join(
_storageDir.path,
'${receipt.captureId}$extension',
);
final tmpPath = '$targetPath.part';
final target = File(targetPath);
if (await target.exists()) {
return targetPath;
}
final uri = _endpoints.captureDownload(receipt.captureId);
final request = http.Request('GET', uri);
request.headers[HttpHeaders.authorizationHeader] =
'Bearer ${receipt.downloadToken}';
final streamed = await _client.send(request).timeout(_downloadTimeout);
if (streamed.statusCode != HttpStatus.ok) {
throw CaptureArtifactDownloadException(
'capture artifact download failed',
statusCode: streamed.statusCode,
);
}
final tmp = File(tmpPath);
final sink = tmp.openWrite();
try {
await streamed.stream.pipe(sink).timeout(_downloadTimeout);
} catch (e) {
await sink.close();
if (await tmp.exists()) {
await tmp.delete();
}
rethrow;
}
await tmp.rename(targetPath);
return targetPath;
}
}
/// Directory under the app temp area where capture artifacts are stored.
Directory captureStorageDirectory() {
return Directory(
p.join(
filesystem.tempDir.path,
'web_search_captures',
filesystem.selectedProfile.uuid,
),
);
}
/// Single source of truth for the capture artifact MIME ↔ extension mapping.
///
/// The capture server's loopback handler probes for exactly these extensions
/// on disk (see [resolveCaptureExtensions]) and looks up the response
/// `Content-Type` against this list (see [captureContentTypeForExtension]),
/// so adding a new artifact format only requires editing this one table.
const _captureArtifactTypes = <({String mime, String extension})>[
(mime: 'text/html', extension: '.html'),
(mime: 'application/pdf', extension: '.pdf'),
(mime: 'image/png', extension: '.png'),
(mime: 'image/jpeg', extension: '.jpg'),
];
/// File extensions the capture server should probe when resolving a stored
/// artifact (HTML + `.jpeg` alias for `image/jpeg`).
const captureSupportedExtensions = <String>[
'.html',
'.pdf',
'.png',
'.jpg',
'.jpeg',
];
/// Maps a capture receipt's MIME [contentType] to the on-disk extension we
/// store the artifact under. The extension is also what the loopback capture
/// server uses to pick the right `Content-Type` when serving back to Gecko.
///
/// Returns `null` for an unknown content type — the caller (downloader)
/// surfaces this as a download error rather than writing an orphan `.bin`
/// the loopback server can't serve.
String? captureFileExtension(String? contentType) {
if (contentType == null) return null;
final mime = contentType.split(';').first.trim().toLowerCase();
for (final type in _captureArtifactTypes) {
if (type.mime == mime) return type.extension;
}
return null;
}
/// True if [contentType] designates an HTML artifact. The sandbox-capture
/// flow (loopback redirects, retry loader, link-click interception) only
/// applies to HTML — PDF / PNG artifacts are loaded directly in a regular
/// tab so Gecko's built-in viewer renders them.
bool isHtmlCaptureContentType(String? contentType) {
if (contentType == null) return false;
return contentType.split(';').first.trim().toLowerCase() == 'text/html';
}
/// Snapshot of the host display, fed to the capture engines as
/// `CaptureDimensions` so PDF/PNG renders match the user's viewport.
///
/// `mobile` is always `true` so captures emulate a mobile viewport.
/// `colorScheme` follows the platform brightness so dark-mode sites render
/// correctly.
CaptureDimensions currentDisplayCaptureDimensions() {
final view =
PlatformDispatcher.instance.implicitView ??
PlatformDispatcher.instance.views.first;
final dpr = view.devicePixelRatio;
final physical = view.physicalSize;
final colorScheme =
PlatformDispatcher.instance.platformBrightness == Brightness.dark
? 'dark'
: 'light';
if (dpr <= 0 || physical.width <= 0 || physical.height <= 0) {
return CaptureDimensions(mobile: true, colorScheme: colorScheme);
}
final logicalWidth = (physical.width / dpr).round();
final logicalHeight = (physical.height / dpr).round();
return CaptureDimensions(
width: logicalWidth,
height: logicalHeight,
screenWidth: logicalWidth,
screenHeight: logicalHeight,
dpi: dpr,
mobile: true,
colorScheme: colorScheme,
);
}
/// Inverse of [captureFileExtension] — used by the loopback capture server
/// to choose a `Content-Type` header from a stored artifact's extension.
/// `.html` carries a charset; binary types don't.
String? captureContentTypeForExtension(String extension) {
final ext = extension.toLowerCase();
if (ext == '.jpeg') return 'image/jpeg';
for (final type in _captureArtifactTypes) {
if (type.extension == ext) {
return type.mime == 'text/html' ? 'text/html; charset=utf-8' : type.mime;
}
}
return null;
}
@Riverpod(keepAlive: true)
CaptureArtifactDownloader captureArtifactDownloader(Ref ref) {
return CaptureArtifactDownloader(
endpoints: ref.watch(searchBackendEndpointsProvider),
storageDir: captureStorageDirectory(),
client: ref.watch(searchProxyHttpClientProvider),
);
}
@@ -0,0 +1,58 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'capture_artifact_downloader.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(captureArtifactDownloader)
final captureArtifactDownloaderProvider = CaptureArtifactDownloaderProvider._();
final class CaptureArtifactDownloaderProvider
extends
$FunctionalProvider<
CaptureArtifactDownloader,
CaptureArtifactDownloader,
CaptureArtifactDownloader
>
with $Provider<CaptureArtifactDownloader> {
CaptureArtifactDownloaderProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'captureArtifactDownloaderProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$captureArtifactDownloaderHash();
@$internal
@override
$ProviderElement<CaptureArtifactDownloader> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
CaptureArtifactDownloader create(Ref ref) {
return captureArtifactDownloader(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CaptureArtifactDownloader value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CaptureArtifactDownloader>(value),
);
}
}
String _$captureArtifactDownloaderHash() =>
r'f746015082a68afc71e2af2e37ac83e3cc856891';
@@ -0,0 +1,631 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/web_search/domain/services/capture_artifact_downloader.dart';
part 'capture_server.g.dart';
// Extensions kept in lock-step with [captureFileExtension] in
// capture_artifact_downloader.dart — the downloader writes the artifact under
// one of these and this server probes for the same set.
class RetryRequest {
final String tabId;
final String captureId;
const RetryRequest({required this.tabId, required this.captureId});
}
/// Loopback HTTP server that exposes locally-stored capture HTML to the
/// in-app GeckoView.
///
/// Gecko rejects `content://` (PDF-only) and `file://` (blocked scheme), so
/// captures are served over `http://127.0.0.1:<port>/captures/<id>.html?t=<token>`.
/// Every published capture gets a fresh random token — other apps binding
/// to the same loopback cannot enumerate captures without guessing it.
///
/// The server also serves a zero-JS "Capturing…" loader page at
/// `/loader?tab=<tabId>&capture=<captureId>[&err=1]`. Sandbox tab navigation
/// is redirected to this loader while the capture is in flight; a
/// `meta http-equiv="refresh"` tag loops until the capture is ready, at which
/// point the loader responds with a `Refresh: 0; url=<captureUrl>` header so
/// GeckoView transitions to the real capture without JavaScript.
class _CaptureEntry {
_CaptureEntry({required this.token});
final String token;
// Cached path to the resolved artifact, populated by the first probe so
// hot paths (loader long-poll wakeups) don't keep stat'ing the FS for
// five extensions per check.
File? cachedFile;
}
class CaptureServer {
CaptureServer({required Directory storageDir}) : _storageDir = storageDir;
final Directory _storageDir;
final Map<String, _CaptureEntry> _entriesById = {};
final Set<String> _failedIds = {};
// Completer per captureId, completed by publish()/markFailed() so loader
// long-polls wake up immediately instead of busy-looping a 200ms poll.
// Recreated after each completion so a subsequent retry can wait again.
final Map<String, Completer<void>> _waiters = {};
final _random = Random.secure();
final _retryController = StreamController<RetryRequest>.broadcast();
HttpServer? _server;
Future<HttpServer>? _starting;
Completer<void> _waiterFor(String captureId) {
return _waiters.putIfAbsent(captureId, Completer<void>.new);
}
void _signalWaiter(String captureId) {
final completer = _waiters.remove(captureId);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
Stream<RetryRequest> get retryRequests => _retryController.stream;
Future<HttpServer> _ensureStarted() async {
final existing = _server;
if (existing != null) return existing;
final pending = _starting;
if (pending != null) return pending;
// Track success and failure: a rejected bind() must clear `_starting`,
// otherwise the stale rejected future is handed back to every subsequent
// caller and the server can never recover (e.g. transient EADDRINUSE).
final future = HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_starting = future
.then((server) {
_server = server;
unawaited(_serve(server));
return server;
})
.whenComplete(() => _starting = null);
return _starting!;
}
Future<void> _serve(HttpServer server) async {
await for (final request in server) {
unawaited(_handle(request));
}
}
Future<void> _handle(HttpRequest request) async {
try {
final segments = request.uri.pathSegments;
if (segments.isEmpty) {
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
return;
}
switch (segments[0]) {
case 'captures':
if (request.method != 'GET' || segments.length != 2) {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleCapture(request, segments[1]);
return;
case 'loader':
if (segments.length == 1) {
if (request.method != 'GET') {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleLoader(request);
return;
}
if (segments.length == 2 && segments[1] == 'wait') {
if (request.method != 'GET') {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleLoaderWait(request);
return;
}
if (segments.length == 2 && segments[1] == 'retry') {
if (request.method != 'POST') {
await _emptyResponse(request, HttpStatus.methodNotAllowed);
return;
}
await _handleRetry(request);
return;
}
await _emptyResponse(request, HttpStatus.notFound);
return;
default:
await _emptyResponse(request, HttpStatus.notFound);
return;
}
} catch (_) {
try {
await request.response.close();
} catch (_) {}
}
}
Future<void> _handleCapture(HttpRequest request, String lastSegment) async {
final extIndex = lastSegment.lastIndexOf('.');
final captureId = extIndex >= 0
? lastSegment.substring(0, extIndex)
: lastSegment;
if (!_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.notFound);
return;
}
final entry = _entriesById[captureId];
final token = request.uri.queryParameters['t'];
if (entry == null || token == null || token != entry.token) {
await _emptyResponse(request, HttpStatus.forbidden);
return;
}
final file = await _resolveCaptureFile(captureId);
if (file == null) {
await _emptyResponse(request, HttpStatus.notFound);
return;
}
final extension = p.extension(file.path).toLowerCase();
final mime =
captureContentTypeForExtension(extension) ?? 'application/octet-stream';
final isHtml = extension == '.html';
final length = await file.length();
request.response.statusCode = HttpStatus.ok;
request.response.headers
..set(HttpHeaders.contentTypeHeader, mime)
..contentLength = length
..set(HttpHeaders.cacheControlHeader, 'no-store')
..set('X-Content-Type-Options', 'nosniff');
if (isHtml) {
// HTML is the only artifact type that can execute script or load
// subresources — clamp it down. PDF/PNG bytes are inert; the browser's
// built-in viewer handles them and CSP would just confuse it.
request.response.headers.set('Content-Security-Policy', _captureCsp);
}
await request.response.addStream(file.openRead());
await request.response.close();
}
/// Resolves the on-disk artifact for [captureId] and caches the result on
/// the entry so subsequent lookups skip the five-extension stat loop. The
/// cache is re-validated on every lookup via a cheap `exists()` so a file
/// removed out-of-band (cache cleanup, manual delete) is detected and the
/// stat loop is re-run; without this, the server would happily hand
/// `request.response.addStream(file.openRead())` a missing file and
/// surface as a mid-stream error rather than the expected 404.
Future<File?> _resolveCaptureFile(String captureId) async {
final entry = _entriesById[captureId];
if (entry == null) return null;
final cached = entry.cachedFile;
if (cached != null) {
if (await cached.exists()) return cached;
entry.cachedFile = null;
}
for (final ext in captureSupportedExtensions) {
final file = File(p.join(_storageDir.path, '$captureId$ext'));
if (await file.exists()) {
entry.cachedFile = file;
return file;
}
}
return null;
}
File? _resolveCaptureFileSync(String captureId) {
final entry = _entriesById[captureId];
if (entry == null) return null;
final cached = entry.cachedFile;
if (cached != null) {
if (cached.existsSync()) return cached;
entry.cachedFile = null;
}
for (final ext in captureSupportedExtensions) {
final file = File(p.join(_storageDir.path, '$captureId$ext'));
if (file.existsSync()) {
entry.cachedFile = file;
return file;
}
}
return null;
}
Future<void> _handleLoader(HttpRequest request) async {
final tabId = request.uri.queryParameters['tab'];
final captureId = request.uri.queryParameters['capture'];
final err = request.uri.queryParameters['err'] == '1';
if (tabId == null ||
captureId == null ||
!_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.notFound);
return;
}
// Render the loader shell immediately. Status transitions are driven
// by an inline JS long-poll against /loader/wait — no meta-refresh,
// no full-page reloads while the user is staring at the spinner.
await _writeLoaderHtml(
request,
body: _loaderShellBody(initialError: err),
tabId: tabId,
captureId: captureId,
);
}
/// JSON long-poll endpoint consumed by the loader shell. Holds the
/// connection open up to ~25s, returning as soon as the capture
/// transitions to `ready` or `failed`. Idle clients reconnect on close,
/// so we keep timeouts well under typical proxy buffering windows.
///
/// Wakes up event-driven via a per-id [Completer] signaled by
/// [publish]/[markFailed]; the older revision busy-polled `isReady` every
/// 200ms which burned CPU on long captures.
Future<void> _handleLoaderWait(HttpRequest request) async {
final captureId = request.uri.queryParameters['capture'];
if (captureId == null || !_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.badRequest);
return;
}
String status;
String? captureUrl;
if (_failedIds.contains(captureId)) {
status = 'failed';
} else if (isReady(captureId)) {
status = 'ready';
} else {
// Race-free: register the waiter before re-checking ready/failed.
// If publish() fires between the early returns above and here, the
// waiter has already been completed via _signalWaiter.
final completer = _waiterFor(captureId);
try {
await completer.future.timeout(const Duration(seconds: 25));
} on TimeoutException {
// Fall through — loader script reconnects on close.
}
if (_failedIds.contains(captureId)) {
status = 'failed';
} else if (isReady(captureId)) {
status = 'ready';
} else {
status = 'pending';
}
}
if (status == 'ready') {
final server = _server;
final entry = _entriesById[captureId];
if (server != null && entry != null) {
captureUrl = _buildCaptureUrl(
server.port,
captureId,
entry.token,
).toString();
}
}
final body = jsonEncode({
'status': status,
if (captureUrl != null) 'url': captureUrl,
});
request.response.statusCode = HttpStatus.ok;
request.response.headers
..contentType = ContentType('application', 'json', charset: 'utf-8')
..set(HttpHeaders.cacheControlHeader, 'no-store');
request.response.write(body);
await request.response.close();
}
Future<void> _handleRetry(HttpRequest request) async {
final tabId = request.uri.queryParameters['tab'];
final captureId = request.uri.queryParameters['capture'];
if (tabId == null ||
captureId == null ||
!_captureIdPattern.hasMatch(captureId)) {
await _emptyResponse(request, HttpStatus.badRequest);
return;
}
_retryController.add(RetryRequest(tabId: tabId, captureId: captureId));
// The loader script in the *current* page picks up the new state via its
// long-poll, so a Refresh header (or full reload) would just discard the
// tab's in-flight fetch. Reply with an empty 204 — the client doesn't
// need the body.
await _emptyResponse(request, HttpStatus.noContent);
}
Future<void> _writeLoaderHtml(
HttpRequest request, {
required String body,
required String tabId,
required String captureId,
}) async {
request.response.statusCode = HttpStatus.ok;
request.response.headers
..contentType = ContentType('text', 'html', charset: 'utf-8')
..set(HttpHeaders.cacheControlHeader, 'no-store')
..set('X-Content-Type-Options', 'nosniff')
..set('Content-Security-Policy', _loaderCsp);
final tabIdJson = jsonEncode(tabId);
final captureIdJson = jsonEncode(captureId);
final html =
'<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>Capturing…</title><style>$_loaderStyles</style>'
'</head><body>$body'
'<script>window.__TAB_ID__=$tabIdJson;'
'window.__CAPTURE_ID__=$captureIdJson;</script>'
'<script>$_loaderScript</script>'
'</body></html>';
request.response.write(html);
await request.response.close();
}
Future<void> _emptyResponse(HttpRequest request, int status) async {
request.response.statusCode = status;
await request.response.close();
}
/// Registers a capture and returns a `http://127.0.0.1:<port>/...` URL
/// that can be loaded in a GeckoView tab.
Future<Uri> publish(String captureId) async {
final server = await _ensureStarted();
_failedIds.remove(captureId);
final entry = _entriesById.putIfAbsent(
captureId,
() => _CaptureEntry(token: _generateToken()),
);
// Wake any in-flight long-poll for this id — the artifact is now
// resolvable, so the poll can return `ready` immediately.
_signalWaiter(captureId);
return _buildCaptureUrl(server.port, captureId, entry.token);
}
/// Marks a capture as failed so any in-flight loader long-polls return
/// immediately with `status: 'failed'`.
void markFailed(String captureId) {
_failedIds.add(captureId);
_signalWaiter(captureId);
}
/// Clears any prior failed flag for the given capture id (e.g. before a
/// retry).
void clearFailed(String captureId) {
_failedIds.remove(captureId);
}
/// Ensures the server is running and returns its port. Useful when callers
/// need to build a loader URL before any capture has been published.
Future<int> ensureStarted() async {
final server = await _ensureStarted();
return server.port;
}
/// Builds the loader URL for the given tab + capture. Also ensures the
/// server is running so callers can use the returned port stably.
Future<Uri> loaderUrl({
required String tabId,
required String captureId,
bool error = false,
}) async {
final port = await ensureStarted();
return Uri(
scheme: 'http',
host: InternetAddress.loopbackIPv4.address,
port: port,
pathSegments: const ['loader'],
queryParameters: {
'tab': tabId,
'capture': captureId,
if (error) 'err': '1',
},
);
}
/// True if [publish] has been called for [captureId] and the artifact file
/// is present on disk. Used by the loader to decide between meta-refresh
/// and redirect.
bool isReady(String captureId) {
final entry = _entriesById[captureId];
if (entry == null) return false;
return _resolveCaptureFileSync(captureId) != null;
}
void revoke(String captureId) {
_entriesById.remove(captureId);
_failedIds.remove(captureId);
_signalWaiter(captureId);
}
Future<void> stop() async {
final server = _server;
_server = null;
_starting = null;
_entriesById.clear();
_failedIds.clear();
// Release every pending long-poll so the request handler can finish.
for (final completer in _waiters.values) {
if (!completer.isCompleted) completer.complete();
}
_waiters.clear();
await server?.close(force: true);
await _retryController.close();
}
Uri _buildCaptureUrl(int port, String captureId, String token) {
// If the artifact is on disk, use its real extension so Gecko picks the
// right viewer (e.g. PDF.js for `.pdf`). Pre-publish (no file yet) we
// fall back to `.html`; the loader long-poll re-resolves the URL once
// the artifact lands.
final file = _resolveCaptureFileSync(captureId);
final ext = file != null ? p.extension(file.path).toLowerCase() : '.html';
return Uri(
scheme: 'http',
host: InternetAddress.loopbackIPv4.address,
port: port,
pathSegments: ['captures', '$captureId$ext'],
queryParameters: {'t': token},
);
}
String _generateToken() {
final bytes = List<int>.generate(24, (_) => _random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
static final _captureIdPattern = RegExp(r'^[A-Za-z0-9_-]+$');
static const _captureCsp =
"default-src 'none'; "
"script-src 'none'; "
"style-src 'unsafe-inline' data:; "
"img-src data: blob:; "
"font-src data:; "
"frame-ancestors 'none'; "
"base-uri 'none'; "
"form-action 'none'";
// The loader is server-controlled HTML on the loopback origin only — it
// never loads remote subresources. Inline script/style are required for
// the long-poll JS and the spinner CSS.
static const _loaderCsp =
"default-src 'none'; "
"script-src 'unsafe-inline'; "
"style-src 'unsafe-inline'; "
"connect-src 'self'; "
"img-src data:; "
"frame-ancestors 'none'; "
"base-uri 'none'; "
"form-action 'self'";
static const _loaderStyles =
'html,body{height:100%}'
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,'
'sans-serif;display:flex;align-items:center;justify-content:center;'
'min-height:100vh;margin:0;background:#0b0d10;color:#e6e8eb}'
'main{text-align:center;max-width:480px;padding:32px;opacity:0;'
'animation:fade .35s ease-out forwards}'
'@keyframes fade{to{opacity:1}}'
'.spinner{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;'
'border:3px solid rgba(255,255,255,.08);border-top-color:#7aa2f7;'
'animation:spin 1s linear infinite}'
'@keyframes spin{to{transform:rotate(360deg)}}'
'h1{font-size:18px;font-weight:600;margin:0 0 8px;letter-spacing:.2px}'
'p{margin:0;color:#9aa1a8;font-size:14px;line-height:1.5}'
'.dots::after{display:inline-block;width:1.2em;text-align:left;'
'animation:dots 1.4s steps(4,end) infinite;content:""}'
'@keyframes dots{0%{content:""}25%{content:"."}50%{content:".."}'
'75%{content:"..."}100%{content:""}}'
'.error h1{color:#f7768e}'
'.actions{margin-top:20px;display:flex;gap:8px;justify-content:center}'
'.btn{padding:10px 16px;border:0;border-radius:10px;cursor:pointer;'
'font:inherit;font-weight:600;background:#7aa2f7;color:#0b0d10}'
'.btn.secondary{background:transparent;color:#9aa1a8;'
'border:1px solid rgba(255,255,255,.12)}'
'.hidden{display:none}'
'@media(prefers-color-scheme:light){body{background:#f5f6f8;'
'color:#1a1d22}.spinner{border-color:rgba(0,0,0,.08);'
'border-top-color:#3b82f6}.btn{background:#3b82f6;color:white}'
'p{color:#52606b}}';
// Inline script for the loader shell. Reads window.__TAB_ID__ and
// window.__CAPTURE_ID__, long-polls /loader/wait, then either redirects
// to the ready capture or flips to the error pane.
//
// Network-error retries are capped (MAX_NET_ERRORS) so a tab whose capture
// service has gone away doesn't busy-loop forever — after the cap the
// loader gives up and shows the error pane with a Retry button (which
// resets the counter via showPending → poll()).
static const _loaderScript = r'''
(function(){
var pending=document.getElementById('pending');
var error=document.getElementById('error');
var retryBtn=document.getElementById('retry');
var tabId=window.__TAB_ID__;
var captureId=window.__CAPTURE_ID__;
var MAX_NET_ERRORS=15;
var netErrors=0;
function showError(){
if(pending)pending.classList.add('hidden');
if(error)error.classList.remove('hidden');
}
function showPending(){
netErrors=0;
if(error)error.classList.add('hidden');
if(pending)pending.classList.remove('hidden');
}
async function poll(){
try{
var r=await fetch('/loader/wait?tab='+encodeURIComponent(tabId)+
'&capture='+encodeURIComponent(captureId),{cache:'no-store'});
if(!r.ok){
if(++netErrors>=MAX_NET_ERRORS){showError();return}
await new Promise(function(res){setTimeout(res,2000)});return poll();
}
netErrors=0;
var data=await r.json();
if(data.status==='ready'&&data.url){location.replace(data.url);return}
if(data.status==='failed'){showError();return}
// pending timeout — reconnect immediately.
return poll();
}catch(e){
if(++netErrors>=MAX_NET_ERRORS){showError();return}
await new Promise(function(res){setTimeout(res,2000)});
return poll();
}
}
if(retryBtn){
retryBtn.addEventListener('click',function(){
showPending();
fetch('/loader/retry?tab='+encodeURIComponent(tabId)+
'&capture='+encodeURIComponent(captureId),
{method:'POST',cache:'no-store'}).catch(function(){});
poll();
});
}
if(error&&!error.classList.contains('hidden')){
// Started in error mode — wait for user retry.
return;
}
poll();
})();
''';
String _loaderShellBody({required bool initialError}) {
final pendingClass = initialError ? 'hidden' : '';
final errorClass = initialError ? 'error' : 'error hidden';
return '<main id="pending" class="$pendingClass">'
'<div class="spinner"></div>'
'<h1>Capturing page<span class="dots"></span></h1>'
'<p>Saving an offline copy. This usually takes a few seconds.</p>'
'</main>'
'<main id="error" class="$errorClass">'
'<h1>Capture failed</h1>'
'<p>The page could not be saved. Check the notification for details.</p>'
'<div class="actions">'
'<button id="retry" class="btn" type="button">Retry</button>'
'</div>'
'</main>';
}
}
@Riverpod(keepAlive: true)
CaptureServer captureServer(Ref ref) {
final server = CaptureServer(storageDir: captureStorageDirectory());
ref.onDispose(() {
unawaited(server.stop());
});
return server;
}
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'capture_server.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(captureServer)
final captureServerProvider = CaptureServerProvider._();
final class CaptureServerProvider
extends $FunctionalProvider<CaptureServer, CaptureServer, CaptureServer>
with $Provider<CaptureServer> {
CaptureServerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'captureServerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$captureServerHash();
@$internal
@override
$ProviderElement<CaptureServer> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
CaptureServer create(Ref ref) {
return captureServer(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CaptureServer value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CaptureServer>(value),
);
}
}
String _$captureServerHash() => r'ddbb9eff1b3d622a44a5ae9523e2f96a37b075fb';
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
show CaptureTabData;
/// Mirror of the capture_tab rows on disk under
/// `<profileDir>/files/sandbox_captures.json`. Kotlin reads this file during
/// pre-Gecko bootstrap, before tab restore can dispatch any load request.
///
/// The schema is intentionally tiny: only the fields Kotlin needs to build a
/// placeholder [SandboxEntry] with `redirectUrl = "about:blank"`. Dart
/// replaces those placeholders with real loopback URLs via
/// [SandboxCaptureApi.resetAll] once [CaptureServer] is running.
///
/// The [_version] field is written at the top of the JSON envelope. Kotlin
/// reads it and refuses to rehydrate any version higher than the one it
/// supports — a forward-incompatible write from a newer Dart side leaves
/// the registry empty for that cold start, which is the safe outcome
/// (sandbox tabs reopen as about:blank rather than loading live URLs).
/// Bump this in lockstep with the Kotlin `SUPPORTED_VERSION` constant.
const _version = 1;
class SandboxCaptureStore {
File _file() {
return File(
p.join(
filesystem.selectedProfileDir.path,
'files',
'sandbox_captures.json',
),
);
}
Future<void> write(List<CaptureTabData> rows) async {
final file = _file();
await file.parent.create(recursive: true);
final payload = jsonEncode({
'version': _version,
'entries': rows
.map((r) {
return {
'tabId': r.tabId,
'captureId': r.captureId,
'sourceUrl': r.sourceUrl,
'status': r.status,
};
})
.toList(growable: false),
});
final tmp = File('${file.path}.part');
await tmp.writeAsString(payload, flush: true);
await tmp.rename(file.path);
}
}