2026-10-11 09:33:25 +03:00
|
|
|
import 'package:actors/actors.dart';
|
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
|
|
2026-10-11 13:35:19 +03:00
|
|
|
import '../../mews/atom.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import '../../mews/document.dart';
|
|
|
|
|
import '../../mews/parser.dart';
|
|
|
|
|
|
|
|
|
|
/// Commands accepted by the fetch actor. Network I/O and XML parsing both
|
|
|
|
|
/// happen in the actor's isolate; results return by message only.
|
|
|
|
|
sealed class FetchCommand {
|
|
|
|
|
const FetchCommand();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetch [url], sending validators when the page is cached. A 304 parses
|
|
|
|
|
/// [cachedSource] in place of a body, and a network failure falls back to it,
|
|
|
|
|
/// so the reader stays online-transparent and works offline.
|
|
|
|
|
class FetchPage extends FetchCommand {
|
|
|
|
|
const FetchPage({required this.url, this.etag, this.lastModified, this.cachedSource});
|
|
|
|
|
|
|
|
|
|
final String url;
|
|
|
|
|
final String? etag;
|
|
|
|
|
final String? lastModified;
|
|
|
|
|
final String? cachedSource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Submit a form's fields to its action URL (spec 4.2).
|
|
|
|
|
class SubmitForm extends FetchCommand {
|
|
|
|
|
const SubmitForm({required this.url, required this.method, required this.fields});
|
|
|
|
|
|
|
|
|
|
final String url;
|
|
|
|
|
final String method;
|
|
|
|
|
final Map<String, String> fields;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:35:19 +03:00
|
|
|
/// Fetch and read a subscribed page's Atom feed (spec 6.2). [url] is absolute
|
|
|
|
|
/// and on the page's own site, which the parser has already checked.
|
|
|
|
|
class FetchFeed extends FetchCommand {
|
|
|
|
|
const FetchFeed({required this.url});
|
|
|
|
|
|
|
|
|
|
final String url;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
/// Parses a page's source without touching the network; used to prepare the
|
|
|
|
|
/// neighboring pages that swipes reveal.
|
|
|
|
|
class ParseSource extends FetchCommand {
|
|
|
|
|
const ParseSource({required this.url, required this.source});
|
|
|
|
|
|
|
|
|
|
final String url;
|
|
|
|
|
final String source;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
sealed class FetchResult {
|
|
|
|
|
const FetchResult();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:35:19 +03:00
|
|
|
/// The posts an Atom feed lists.
|
|
|
|
|
class FetchFeedOk extends FetchResult {
|
|
|
|
|
const FetchFeedOk({required this.url, required this.entries});
|
|
|
|
|
|
|
|
|
|
final String url;
|
|
|
|
|
final List<MewsEntry> entries;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
class FetchPageOk extends FetchResult {
|
|
|
|
|
const FetchPageOk({
|
|
|
|
|
required this.page,
|
|
|
|
|
required this.source,
|
|
|
|
|
this.etag,
|
|
|
|
|
this.lastModified,
|
|
|
|
|
this.notModified = false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final MewsPage page;
|
|
|
|
|
|
|
|
|
|
/// The markup the page was parsed from, for the cache.
|
|
|
|
|
final String source;
|
|
|
|
|
final String? etag;
|
|
|
|
|
final String? lastModified;
|
|
|
|
|
|
|
|
|
|
/// True when the page came from the local cache (304 or offline).
|
|
|
|
|
final bool notModified;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FetchFormOk extends FetchResult {
|
|
|
|
|
const FetchFormOk(this.page);
|
|
|
|
|
|
|
|
|
|
final MewsPage page;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FetchNetworkError extends FetchResult {
|
|
|
|
|
const FetchNetworkError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FetchHttpStatusError extends FetchResult {
|
|
|
|
|
const FetchHttpStatusError(this.status);
|
|
|
|
|
|
|
|
|
|
final int status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FetchParseError extends FetchResult {
|
|
|
|
|
const FetchParseError(this.message, this.source);
|
|
|
|
|
|
|
|
|
|
final String message;
|
|
|
|
|
|
|
|
|
|
/// The markup that failed to parse, for the "show page text" option.
|
|
|
|
|
final String source;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FetchHandler with Handler<FetchCommand, FetchResult> {
|
|
|
|
|
// Identifies the client to servers that vary behavior by user agent, like
|
|
|
|
|
// the http-to-https redirects some Mews sites run (spec 7.2).
|
|
|
|
|
static const _userAgent = 'Mews/0.1 (Mews client)';
|
|
|
|
|
|
|
|
|
|
final http.Client _client = http.Client();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<FetchResult> handle(FetchCommand message) async {
|
|
|
|
|
return switch (message) {
|
|
|
|
|
FetchPage(:final url, :final etag, :final lastModified, :final cachedSource) =>
|
|
|
|
|
await _fetchPage(url, etag, lastModified, cachedSource),
|
|
|
|
|
SubmitForm(:final url, :final method, :final fields) =>
|
|
|
|
|
await _submitForm(url, method, fields),
|
2026-10-11 13:35:19 +03:00
|
|
|
FetchFeed(:final url) => await _fetchFeed(url),
|
2026-10-11 11:05:03 +03:00
|
|
|
ParseSource(:final url, :final source) => _parse(source, url),
|
2026-10-11 09:33:25 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<FetchResult> _fetchPage(
|
|
|
|
|
String url, String? etag, String? lastModified, String? cachedSource) async {
|
|
|
|
|
final headers = <String, String>{'User-Agent': _userAgent};
|
|
|
|
|
if (etag != null) headers['If-None-Match'] = etag;
|
|
|
|
|
if (lastModified != null) headers['If-Modified-Since'] = lastModified;
|
|
|
|
|
|
|
|
|
|
http.Response response;
|
|
|
|
|
try {
|
|
|
|
|
response = await _client
|
|
|
|
|
.get(Uri.parse(url), headers: headers)
|
|
|
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return _fromCacheOrError(url, cachedSource, etag, lastModified);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.statusCode == 304) {
|
|
|
|
|
return _fromCacheOrError(url, cachedSource, etag, lastModified);
|
|
|
|
|
}
|
|
|
|
|
if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode);
|
|
|
|
|
|
|
|
|
|
final newEtag = response.headers['etag'];
|
|
|
|
|
final newLastModified = response.headers['last-modified'];
|
|
|
|
|
return _parse(response.body, url, etag: newEtag, lastModified: newLastModified);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The page could not be revalidated; use the cached copy when there is one.
|
|
|
|
|
FetchResult _fromCacheOrError(String url, String? cachedSource, String? etag, String? lastModified) {
|
|
|
|
|
if (cachedSource == null) return const FetchNetworkError();
|
|
|
|
|
return _parse(cachedSource, url, etag: etag, lastModified: lastModified, notModified: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FetchResult _parse(String source, String url,
|
|
|
|
|
{String? etag, String? lastModified, bool notModified = false}) {
|
|
|
|
|
try {
|
|
|
|
|
final page = parseMewsPage(source: source, url: url);
|
|
|
|
|
return FetchPageOk(
|
|
|
|
|
page: page, source: source, etag: etag, lastModified: lastModified, notModified: notModified);
|
|
|
|
|
} on MewsParseException catch (e) {
|
|
|
|
|
return FetchParseError(e.message, source);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:35:19 +03:00
|
|
|
Future<FetchResult> _fetchFeed(String url) async {
|
|
|
|
|
final http.Response response;
|
|
|
|
|
try {
|
|
|
|
|
response = await _client
|
|
|
|
|
.get(Uri.parse(url), headers: {'User-Agent': _userAgent})
|
|
|
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return const FetchNetworkError();
|
|
|
|
|
}
|
|
|
|
|
if (response.statusCode != 200) {
|
|
|
|
|
return FetchHttpStatusError(response.statusCode);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return FetchFeedOk(
|
|
|
|
|
url: url,
|
|
|
|
|
entries: parseAtomEntries(source: response.body, feedUrl: url),
|
|
|
|
|
);
|
|
|
|
|
} on MewsParseException catch (e) {
|
|
|
|
|
return FetchParseError(e.message, response.body);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<FetchResult> _submitForm(String url, String method, Map<String, String> fields) async {
|
|
|
|
|
final headers = {'User-Agent': _userAgent};
|
2026-10-11 13:05:07 +03:00
|
|
|
final http.Response response;
|
|
|
|
|
try {
|
|
|
|
|
if (method == 'post') {
|
|
|
|
|
response = await _client
|
|
|
|
|
.post(Uri.parse(url), headers: headers, body: fields)
|
|
|
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
|
} else {
|
|
|
|
|
response = await _client
|
|
|
|
|
.get(Uri.parse(url).replace(queryParameters: fields), headers: headers)
|
|
|
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// A refused connection, a timeout or an address the client cannot speak
|
|
|
|
|
// are all one failed submit to the reader.
|
|
|
|
|
return const FetchNetworkError();
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode);
|
|
|
|
|
return _parse(response.body, url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> close() async => _client.close();
|
|
|
|
|
}
|