优化项目结构

This commit is contained in:
DelLevin-Home
2026-07-05 13:58:25 +08:00
parent 82aca12402
commit de180eee47
41 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,472 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:xml/xml.dart';
import 'reader_models.dart';
/// EPUB 解析器 - 从 ZIP 归档中解析 EPUB 结构
class EpubParser {
/// 从文件路径解析 EPUB
Future<EpubBookInfo?> parseFromFile(String filePath,
{String? fileName}) async {
try {
final bytes = await File(filePath).readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
return _parseFromArchive(archive, fileName: fileName);
} catch (e) {
return null;
}
}
EpubBookInfo? _parseFromArchive(Archive archive, {String? fileName}) {
try {
// 检查加密(只拒绝真正阻止内容读取的加密,忽略字体混淆等)
final encFile = archive.findFile('META-INF/encryption.xml');
if (encFile != null) {
try {
final encContent = utf8.decode(encFile.content as List<int>);
final encDoc = XmlDocument.parse(encContent);
// 如果有 EncryptedData 且不是字体文件,则拒绝
final encryptedData = encDoc.findAllElements('EncryptedData');
for (final ed in encryptedData) {
final cipherRef = ed.findAllElements('CipherReference').firstOrNull;
final uri = cipherRef?.getAttribute('URI') ?? '';
// 非字体文件被加密 → 真正的 DRM
if (!uri.endsWith('.ttf') &&
!uri.endsWith('.otf') &&
!uri.endsWith('.woff') &&
!uri.endsWith('.woff2')) {
debugPrint('[EpubParser] 内容加密的 EPUB不支持: $uri');
return null;
}
}
debugPrint('[EpubParser] 仅字体混淆,继续解析');
} catch (e) {
debugPrint('[EpubParser] encryption.xml 解析失败,跳过: $e');
}
}
final opfPath = _findOpfPath(archive);
debugPrint('[EpubParser] OPF 路径: $opfPath');
if (opfPath == null) return null;
final opfFile = archive.findFile(opfPath);
debugPrint('[EpubParser] OPF 文件: ${opfFile != null ? '找到' : '未找到'}');
if (opfFile == null) return null;
final opfContent = utf8.decode(opfFile.content as List<int>);
debugPrint('[EpubParser] OPF 内容长度: ${opfContent.length}');
return _parseOpf(opfContent, opfPath, archive, fileName);
} catch (e, stack) {
debugPrint('[EpubParser] _parseFromArchive 失败: $e');
debugPrint('[EpubParser] $stack');
return null;
}
}
/// 查找 OPF 文件路径
String? _findOpfPath(Archive archive) {
// 策略1: 解析 container.xml
final containerFile = archive.findFile('META-INF/container.xml');
if (containerFile != null) {
try {
final content = utf8.decode(containerFile.content as List<int>);
final doc = XmlDocument.parse(content);
final rootfile = doc.findAllElements('rootfile').firstOrNull;
if (rootfile != null) {
final fullPath = rootfile.getAttribute('full-path');
if (fullPath != null) return fullPath;
}
} catch (_) {}
}
// 策略2: 常见路径
const commonPaths = [
'content.opf',
'OEBPS/content.opf',
'OPS/content.opf',
'EPUB/content.opf',
];
for (final path in commonPaths) {
if (archive.findFile(path) != null) return path;
}
// 策略3: 扫描 .opf 文件
for (final file in archive.files) {
if (file.name.endsWith('.opf')) return file.name;
}
return null;
}
/// 解析 OPF 文件
EpubBookInfo? _parseOpf(
String content, String opfPath, Archive archive, String? fileName) {
final opfDir =
opfPath.contains('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
final doc = XmlDocument.parse(content);
final package = doc.rootElement;
final version = package.getAttribute('version') ?? '2.0';
final metadata = package.findElements('metadata').firstOrNull;
final manifest = package.findElements('manifest').firstOrNull;
final spine = package.findElements('spine').firstOrNull;
debugPrint('[EpubParser] metadata=${metadata != null}, manifest=${manifest != null}, spine=${spine != null}');
if (metadata == null || manifest == null || spine == null) return null;
// 解析 manifest (id -> href)
final manifestMap = <String, String>{};
final manifestProperties = <String, String>{};
for (final item in manifest.findElements('item')) {
final id = item.getAttribute('id');
final href = item.getAttribute('href');
final properties = item.getAttribute('properties');
if (id != null && href != null) {
manifestMap[id] = _resolveRelativePath(opfDir, _normalizePath(href));
if (properties != null) manifestProperties[id] = properties;
}
}
// 解析 metadata
final titles = _findByLocalName(metadata, 'title')
.map((e) => e.innerText.trim())
.where((t) => t.isNotEmpty)
.toList();
final authors = _findByLocalName(metadata, 'creator')
.map((e) => e.innerText.trim())
.where((a) => a.isNotEmpty)
.toList();
final description =
_findByLocalName(metadata, 'description').firstOrNull?.innerText.trim();
// 解析 spine
final spineItems = <SpineItem>[];
final spineIndexMap = <String, int>{};
int index = 0;
for (final itemref in spine.findElements('itemref')) {
final idref = itemref.getAttribute('idref');
final linearAttr = itemref.getAttribute('linear');
final isLinear =
linearAttr == null || linearAttr.toLowerCase() != 'no';
if (idref != null && manifestMap.containsKey(idref)) {
final href = manifestMap[idref]!;
spineItems.add(SpineItem(
index: index,
href: href,
idref: idref,
linear: isLinear,
));
spineIndexMap[href] = index;
index++;
}
}
// 解析 TOC
List<TocEntry> toc = [];
// EPUB 3 NAV 文档
String? navId;
for (final entry in manifestProperties.entries) {
if (_containsWholeWord(entry.value, 'nav')) {
navId = entry.key;
break;
}
}
if (navId != null && manifestMap.containsKey(navId)) {
final navPath = manifestMap[navId]!;
final navFile = archive.findFile(navPath);
if (navFile != null) {
try {
final navContent = utf8.decode(navFile.content as List<int>);
final navDir = navPath.contains('/')
? navPath.substring(0, navPath.lastIndexOf('/'))
: '';
toc = _parseNav(navContent, navDir, spineIndexMap);
} catch (_) {}
}
}
// EPUB 2 NCX 回退
if (toc.isEmpty) {
final tocId = spine.getAttribute('toc');
if (tocId != null && manifestMap.containsKey(tocId)) {
final tocPath = manifestMap[tocId]!;
final tocFile = archive.findFile(tocPath);
if (tocFile != null) {
try {
final tocContent = utf8.decode(tocFile.content as List<int>);
final ncxDir = tocPath.contains('/')
? tocPath.substring(0, tocPath.lastIndexOf('/'))
: '';
toc = _parseNcx(tocContent, ncxDir, spineIndexMap);
} catch (_) {}
}
}
}
// 最终回退: 从 spine 生成平坦目录
if (toc.isEmpty) {
int chNum = 1;
for (final si in spineItems) {
if (!si.linear) continue;
toc.add(TocEntry(
label: '$chNum',
href: '${si.href}#top',
spineIndex: si.index,
));
chNum++;
}
}
// 检测封面
String? coverHref = _detectCover(metadata, manifestMap, manifestProperties, archive, opfDir);
final title = titles.isNotEmpty
? titles.first
: (fileName ?? '').split('/').last.split('.').first;
return EpubBookInfo(
title: title,
author: authors.isNotEmpty ? authors.first : '',
authors: authors,
description: description,
coverHref: coverHref,
opfRootPath: opfPath,
epubVersion: version,
spine: spineItems,
toc: toc,
);
}
/// 检测封面图片路径(相对于 OPF 目录)
String? _detectCover(
XmlElement metadata,
Map<String, String> manifestMap,
Map<String, String> manifestProperties,
Archive archive,
String opfDir,
) {
// 策略1: meta name="cover"
final coverMeta = metadata
.findAllElements('meta')
.where((e) => e.getAttribute('name') == 'cover')
.firstOrNull;
if (coverMeta != null) {
final coverId = coverMeta.getAttribute('content');
if (coverId != null && manifestMap.containsKey(coverId)) {
final href = manifestMap[coverId]!;
if (_isImageFile(href)) return href;
}
}
// 策略2: manifest 属性包含 cover-image
for (final entry in manifestProperties.entries) {
if (_containsWholeWord(entry.value, 'cover-image')) {
if (manifestMap.containsKey(entry.key)) {
final href = manifestMap[entry.key]!;
if (_isImageFile(href)) return href;
}
}
}
// 策略3: 常见文件名
for (final key in manifestMap.keys) {
final lower = key.toLowerCase();
if (lower == 'cover.jpg' ||
lower == 'cover.png' ||
lower == 'cover.jpeg' ||
lower == 'cover.webp') {
return manifestMap[key]!;
}
}
// 策略4: guide 中的 cover 引用
final guideElement = XmlDocument.parse(
'<root>${metadata.parent?.toXmlString() ?? ''}</root>')
.rootElement
.findElements('guide')
.firstOrNull;
if (guideElement != null) {
for (final ref in guideElement.findElements('reference')) {
final type = ref.getAttribute('type') ?? '';
if (type.toLowerCase() == 'cover') {
final href = ref.getAttribute('href');
if (href != null) {
final resolved = _resolveRelativePath(opfDir, href);
if (_isImageFile(resolved)) return resolved;
// href 可能指向一个 XHTML 文件,需要从中提取图片
final coverFile = archive.findFile(resolved);
if (coverFile != null) {
try {
final html = utf8.decode(coverFile.content as List<int>);
final imgSrc = _extractFirstImage(html);
if (imgSrc != null) {
final hrefDir = resolved.contains('/')
? resolved.substring(0, resolved.lastIndexOf('/'))
: '';
return _resolveRelativePath(hrefDir, imgSrc);
}
} catch (_) {}
}
}
}
}
}
return null;
}
String? _extractFirstImage(String html) {
final imgReg = RegExp(r'<img[^>]+src="([^">]+)"', caseSensitive: false);
final match = imgReg.firstMatch(html);
return match?.group(1);
}
/// 解析 EPUB 3 NAV 文档
List<TocEntry> _parseNav(
String content, String navDir, Map<String, int> spineIndexMap) {
try {
final doc = XmlDocument.parse(content);
final navElement = doc.findAllElements('nav').where((el) {
final epubType = el.getAttribute('epub:type') ??
el.getAttribute('type') ??
'';
return _containsWholeWord(epubType, 'toc');
}).firstOrNull;
if (navElement == null) return [];
final rootOl = navElement.childElements
.where((el) => el.localName == 'ol')
.firstOrNull;
if (rootOl == null) return [];
return _parseNavListItems(rootOl.findElements('li'), navDir, spineIndexMap);
} catch (_) {
return [];
}
}
List<TocEntry> _parseNavListItems(
Iterable<XmlElement> items, String baseDir, Map<String, int> spineIndexMap) {
final entries = <TocEntry>[];
for (final li in items) {
final anchor = li.childElements
.where((el) => el.localName == 'a' || el.localName == 'span')
.firstOrNull;
final label =
anchor?.innerText.trim().isNotEmpty == true ? anchor!.innerText.trim() : 'Chapter';
final hrefValue =
anchor?.localName == 'a' ? anchor!.getAttribute('href') : null;
String href = '';
int spineIdx = -1;
if (hrefValue != null && hrefValue.trim().isNotEmpty) {
final resolved = _resolveRelativePath(baseDir, hrefValue);
href = resolved;
final pathOnly = href.split('#').first;
spineIdx = spineIndexMap[pathOnly] ?? -1;
}
final nestedOl = li.childElements
.where((el) => el.localName == 'ol')
.firstOrNull;
final children = nestedOl != null
? _parseNavListItems(nestedOl.findElements('li'), baseDir, spineIndexMap)
: <TocEntry>[];
entries.add(TocEntry(
label: label,
href: href,
spineIndex: spineIdx,
children: children,
));
}
return entries;
}
/// 解析 EPUB 2 NCX 文档
List<TocEntry> _parseNcx(
String content, String baseDir, Map<String, int> spineIndexMap) {
try {
final doc = XmlDocument.parse(content);
final navMap = doc.findAllElements('navMap').firstOrNull;
if (navMap == null) return [];
return _parseNavPoints(navMap.findElements('navPoint'), baseDir, spineIndexMap);
} catch (_) {
return [];
}
}
List<TocEntry> _parseNavPoints(
Iterable<XmlElement> navPoints, String baseDir, Map<String, int> spineIndexMap) {
final entries = <TocEntry>[];
for (final np in navPoints) {
final label = np
.findElements('navLabel')
.firstOrNull
?.findElements('text')
.firstOrNull
?.innerText
.trim() ??
'Chapter';
final src = np.findElements('content').firstOrNull?.getAttribute('src') ?? '';
final resolved = _resolveRelativePath(baseDir, src);
final pathOnly = resolved.split('#').first;
final spineIdx = spineIndexMap[pathOnly] ?? -1;
final children = _parseNavPoints(np.findElements('navPoint'), baseDir, spineIndexMap);
entries.add(TocEntry(
label: label,
href: resolved,
spineIndex: spineIdx,
children: children,
));
}
return entries;
}
// ─── 辅助方法 ─────────────────────────────────────────────────
Iterable<XmlElement> _findByLocalName(XmlElement parent, String name) {
return parent.descendantElements.where((e) => e.localName == name);
}
bool _containsWholeWord(String? value, String word) {
if (value == null || value.trim().isEmpty) return false;
return RegExp('\\b${RegExp.escape(word)}\\b', caseSensitive: false)
.hasMatch(value);
}
String _resolveRelativePath(String baseDir, String relativePath) {
if (baseDir.isEmpty) return relativePath;
final baseUri = Uri.parse(baseDir.endsWith('/') ? baseDir : '$baseDir/');
final resolved = baseUri.resolve(relativePath);
String result = resolved.toString();
if (result.startsWith('/')) result = result.substring(1);
return Uri.decodeFull(result);
}
String _normalizePath(String path) {
path = path.trim();
while (path.startsWith('/')) {
path = path.substring(1);
}
while (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
path = path.replaceAll(RegExp(r'/+'), '/');
return path;
}
bool _isImageFile(String path) {
final lower = path.toLowerCase();
return lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.png') ||
lower.endsWith('.webp');
}
}

View File

@@ -0,0 +1,155 @@
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
import 'epub_parser.dart';
import 'reader_dao.dart';
import 'reader_models.dart';
/// EPUB 服务层 - 管理导入、解压、删除
class EpubService {
final ReaderDao _dao = ReaderDao();
final EpubParser _parser = EpubParser();
static const _uuid = Uuid();
/// 导入 EPUB 文件
/// 返回 {'bookId': ..., 'title': ...} 或 null解析失败
Future<Map<String, dynamic>?> importBook(String sourcePath) async {
final bookId = _uuid.v4();
final now = DateTime.now().toIso8601String();
final fileName = p.basename(sourcePath);
// 复制 EPUB 到永久存储FilePicker 临时文件会被清理)
final appDir = await getApplicationDocumentsDirectory();
final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
if (!await bookDir.exists()) await bookDir.create(recursive: true);
final permanentPath = p.join(bookDir.path, 'book.epub');
await File(sourcePath).copy(permanentPath);
// 从永久副本解析
final info = await _parser.parseFromFile(
permanentPath,
fileName: fileName,
);
if (info == null) return null;
// 解压到临时目录
final extractDir = await getExtractDir(bookId);
await _extractEpub(permanentPath, extractDir);
// 提取封面
String? coverPath;
if (info.coverHref != null) {
coverPath = await _extractCover(info, extractDir, bookId);
}
// 写入数据库file_path 存永久路径)
await _dao.insertReaderBook({
'id': bookId,
'title': info.title,
'author': info.author,
'cover_path': coverPath,
'file_path': permanentPath,
'file_name': fileName,
'file_extension': 'epub',
'last_read_cfi': '',
'reading_percentage': 0.0,
'created_at': now,
'updated_at': now,
'is_deleted': 0,
});
return {'bookId': bookId, 'title': info.title};
}
/// 解压 EPUB 到目标目录
Future<void> _extractEpub(String sourcePath, String targetDir) async {
final dir = Directory(targetDir);
if (await dir.exists()) await dir.delete(recursive: true);
await dir.create(recursive: true);
final bytes = await File(sourcePath).readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
final filePath = p.join(targetDir, file.name);
if (file.isFile) {
final outFile = File(filePath);
await outFile.parent.create(recursive: true);
await outFile.writeAsBytes(file.content as List<int>);
} else {
await Directory(filePath).create(recursive: true);
}
}
}
/// 提取封面图片
Future<String?> _extractCover(
EpubBookInfo info, String extractDir, String bookId) async {
try {
final opfDir = info.opfRootPath.contains('/')
? info.opfRootPath.substring(0, info.opfRootPath.lastIndexOf('/'))
: '';
final coverRelPath = opfDir.isEmpty
? info.coverHref!
: '$opfDir/${info.coverHref!}';
final coverFile = File(p.join(extractDir, coverRelPath));
if (!await coverFile.exists()) return null;
// 保存到 epub_books/{bookId}/ 目录下
final appDir = await getApplicationDocumentsDirectory();
final coverDir = p.join(appDir.path, 'epub_books', bookId);
await Directory(coverDir).create(recursive: true);
final ext = p.extension(coverFile.path).toLowerCase();
final destPath = p.join(coverDir, 'cover$ext');
await coverFile.copy(destPath);
return destPath;
} catch (_) {
return null;
}
}
/// 获取解压目录
Future<String> getExtractDir(String bookId) async {
final tempDir = await getTemporaryDirectory();
return p.join(tempDir.path, 'epub', bookId);
}
/// 确保已解压(如果临时目录被清理则重新解压)
/// 返回解压目录路径,失败返回 null
Future<String?> ensureExtracted(String bookId, String filePath) async {
final extractDir = await getExtractDir(bookId);
final dir = Directory(extractDir);
if (await dir.exists()) {
final files = dir.listSync();
if (files.isNotEmpty) return extractDir;
}
// 重新解压
if (!await File(filePath).exists()) return null;
await _extractEpub(filePath, extractDir);
return extractDir;
}
/// 删除书籍
Future<void> deleteBook(String bookId) async {
// 清理解压目录
try {
final extractDir = await getExtractDir(bookId);
final dir = Directory(extractDir);
if (await dir.exists()) await dir.delete(recursive: true);
} catch (_) {}
// 清理 epub_books/{bookId}/ 目录epub + 封面)
try {
final appDir = await getApplicationDocumentsDirectory();
final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
if (await bookDir.exists()) await bookDir.delete(recursive: true);
} catch (_) {}
// 软删除数据库记录
await _dao.deleteReaderBook(bookId);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
class EpubStreamService {
String? _currentBookPath;
String? _pendingBookPath;
Future<void>? _openBookFuture;
/// Cached decoded archive for the current book.
Archive? _cachedArchive;
Future<void> warmUp() async {}
Future<void> openBook(String epubPath) {
if (_currentBookPath == epubPath && _cachedArchive != null) {
return Future.value();
}
if (_pendingBookPath == epubPath && _openBookFuture != null) {
return _openBookFuture!;
}
_pendingBookPath = epubPath;
_openBookFuture = _doOpenBook(epubPath);
return _openBookFuture!;
}
Future<void> _doOpenBook(String epubPath) async {
try {
final bytes = await File(epubPath).readAsBytes();
_cachedArchive = ZipDecoder().decodeBytes(bytes);
_currentBookPath = epubPath;
} catch (e) {
_currentBookPath = null;
_cachedArchive = null;
rethrow;
} finally {
if (_pendingBookPath == epubPath) {
_pendingBookPath = null;
_openBookFuture = null;
}
}
}
/// Read a single file from the currently open EPUB archive.
/// Returns the file bytes, or null if not found / no book loaded.
Future<Uint8List?> readFileFromEpub({
required String targetFilePath,
String? epubPath,
}) async {
if (epubPath != null && epubPath != _currentBookPath) {
await openBook(epubPath);
}
if (_currentBookPath == null || _cachedArchive == null) {
return null;
}
final file = _cachedArchive!.findFile(targetFilePath);
if (file == null || file.content == null) {
return null;
}
return file.content is Uint8List
? file.content as Uint8List
: Uint8List.fromList(file.content as List<int>);
}
void dispose() {
_cachedArchive = null;
_currentBookPath = null;
_pendingBookPath = null;
_openBookFuture = null;
}
String getMimeType(String filePath) {
final ext = p.extension(filePath).toLowerCase().replaceAll('.', '');
return _mimeTypeMap[ext] ?? 'application/octet-stream';
}
static const _mimeTypeMap = {
'html': 'text/html',
'htm': 'text/html',
'xhtml': 'application/xhtml+xml',
'xml': 'application/xml',
'css': 'text/css',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ttf': 'font/ttf',
'otf': 'font/otf',
'woff': 'font/woff',
'woff2': 'font/woff2',
'js': 'application/javascript',
};
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'reader_scripts.dart';
/// 阅读器主题预设
class ReaderThemePreset {
final String name;
final Color surface;
final Color onSurface;
final bool isDark;
const ReaderThemePreset({
required this.name,
required this.surface,
required this.onSurface,
this.isDark = false,
});
}
class ReaderThemePresets {
static const List<ReaderThemePreset> presets = [
ReaderThemePreset(name: '跟随App', surface: Colors.white, onSurface: Colors.black),
ReaderThemePreset(name: '纯白', surface: Color(0xFFFFFFFF), onSurface: Color(0xFF1A1A1A)),
ReaderThemePreset(name: '护眼', surface: Color(0xFFF4ECD8), onSurface: Color(0xFF5B4636)),
ReaderThemePreset(name: '抹茶', surface: Color(0xFFF6FBF5), onSurface: Color(0xFF2E3E2E)),
ReaderThemePreset(name: '樱花', surface: Color(0xFFFFF8F8), onSurface: Color(0xFF4A2030)),
ReaderThemePreset(name: '午夜蓝', surface: Color(0xFFF7F9FC), onSurface: Color(0xFF1A2A3A)),
ReaderThemePreset(name: '深色', surface: Color(0xFF191919), onSurface: Color(0xFFD4D4D4), isDark: true),
ReaderThemePreset(name: '深色护眼', surface: Color(0xFF1C1A18), onSurface: Color(0xFFC8B8A0), isDark: true),
ReaderThemePreset(name: '咖啡', surface: Color(0xFFFCF8F3), onSurface: Color(0xFF3E2E1E)),
];
}
class EpubTheme {
final double zoom;
final bool shouldOverrideTextColor;
final ColorScheme colorScheme;
final Color? overridePrimaryColor;
final EdgeInsets padding;
/// File name (with extension) of the custom font, or null for epub default.
final String? fontFileName;
/// When true, force the custom font on top of the epub's own font rules.
final bool overrideFontFamily;
EpubTheme({
required this.zoom,
required this.shouldOverrideTextColor,
required this.colorScheme,
this.overridePrimaryColor,
required this.padding,
this.fontFileName,
this.overrideFontFamily = false,
});
bool get isDark => colorScheme.brightness == Brightness.dark;
Color get surfaceColor => colorScheme.surface;
EpubTheme copyWith({
double? zoom,
bool? shouldOverrideTextColor,
ColorScheme? colorScheme,
Color? overridePrimaryColor,
EdgeInsets? padding,
Object? fontFileName = _kUnset,
bool? overrideFontFamily,
}) {
return EpubTheme(
zoom: zoom ?? this.zoom,
shouldOverrideTextColor:
shouldOverrideTextColor ?? this.shouldOverrideTextColor,
colorScheme: colorScheme ?? this.colorScheme,
overridePrimaryColor: overridePrimaryColor ?? this.overridePrimaryColor,
padding: padding ?? this.padding,
fontFileName: identical(fontFileName, _kUnset)
? this.fontFileName
: fontFileName as String?,
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
);
}
static const Object _kUnset = Object();
Map<String, dynamic> toThemeMap() {
return {
'padding': {'top': padding.top, 'left': padding.left},
'theme': {
'zoom': zoom,
'shouldOverrideTextColor': shouldOverrideTextColor,
'primaryColor': overridePrimaryColor != null
? colorToMap(overridePrimaryColor!)
: colorToMap(colorScheme.primary),
'onPrimaryColor': colorToMap(colorScheme.onPrimary),
'secondaryColor': colorToMap(colorScheme.secondary),
'onSecondaryColor': colorToMap(colorScheme.onSecondary),
'errorColor': colorToMap(colorScheme.error),
'onErrorColor': colorToMap(colorScheme.onError),
'surfaceColor': colorToMap(colorScheme.surface),
'onSurfaceColor': colorToMap(colorScheme.onSurface),
'primaryContainerColor': colorToMap(colorScheme.primaryContainer),
'onSurfaceVariantColor': colorToMap(colorScheme.onSurfaceVariant),
'outlineVariantColor': colorToMap(colorScheme.outlineVariant),
'surfaceContainerColor': colorToMap(colorScheme.surfaceContainer),
'surfaceContainerHighColor': colorToMap(
colorScheme.surfaceContainerHigh,
),
'fontFileName': fontFileName,
'overrideFontFamily': overrideFontFamily,
},
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EpubTheme &&
other.zoom == zoom &&
other.shouldOverrideTextColor == shouldOverrideTextColor &&
other.colorScheme == colorScheme &&
other.overridePrimaryColor == overridePrimaryColor &&
other.padding == padding &&
other.fontFileName == fontFileName &&
other.overrideFontFamily == overrideFontFamily;
}
@override
int get hashCode => Object.hash(
zoom,
shouldOverrideTextColor,
colorScheme,
overridePrimaryColor,
padding,
fontFileName,
overrideFontFamily,
);
}

View File

@@ -0,0 +1,258 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:path_provider/path_provider.dart';
import 'epub_stream_service.dart';
/// Simple file reference with path and optional anchor.
class Href {
final String path;
final String anchor;
const Href({required this.path, this.anchor = 'top'});
@override
String toString() => '$path#$anchor';
}
/// WebView request handler for streaming EPUB content.
/// Intercepts requests to virtual domain and serves files from compressed EPUB.
class EpubWebViewHandler {
final EpubStreamService _streamService;
/// Virtual domain for EPUB content.
/// Format: epub://localhost/book/{fileHash}/{filePath}
static const String virtualDomain = 'localhost';
static const String virtualScheme = 'epub';
static const _headers = {'Cache-Control': 'public, max-age=31536000'};
EpubWebViewHandler({required EpubStreamService streamService})
: _streamService = streamService;
/// Cached documents directory path.
static String? _documentsPath;
static Future<String> getDocumentsPath() async {
if (_documentsPath != null) return _documentsPath!;
final dir = await getApplicationDocumentsDirectory();
_documentsPath = '${dir.path}/';
return _documentsPath!;
}
/// Create WebView resource request handler.
/// This should be set as the shouldInterceptRequest callback.
Future<WebResourceResponse?> handleRequest({
required String epubPath,
required String fileHash,
required WebUri requestUrl,
}) async {
try {
// Serve user-imported fonts.
if (isFontRequest(requestUrl)) {
final fontResult = await _readFontFile(requestUrl);
if (fontResult == null) {
return WebResourceResponse(
statusCode: 404,
reasonPhrase: 'Not Found',
data: Uint8List.fromList('Font not found'.codeUnits),
);
}
return WebResourceResponse(
contentType: fontResult.$2,
statusCode: 200,
reasonPhrase: 'OK',
data: fontResult.$1,
headers: _headers,
);
}
// Read file from EPUB
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
if (result == null) {
return WebResourceResponse(
statusCode: 404,
reasonPhrase: 'Not Found',
data: Uint8List.fromList('File not found'.codeUnits),
);
}
return WebResourceResponse(
contentType: result.$2,
statusCode: 200,
reasonPhrase: 'OK',
data: result.$1,
headers: _headers,
);
} catch (e) {
return WebResourceResponse(
statusCode: 500,
reasonPhrase: 'Internal Server Error',
data: Uint8List.fromList('Error: $e'.codeUnits),
);
}
}
Future<CustomSchemeResponse?> handleRequestWithCustomScheme({
required String epubPath,
required String fileHash,
required WebUri requestUrl,
}) async {
try {
// Serve user-imported fonts.
if (isFontRequest(requestUrl)) {
final fontResult = await _readFontFile(requestUrl);
if (fontResult == null) {
return CustomSchemeResponse(
contentType: 'text/plain',
data: Uint8List.fromList('Font not found'.codeUnits),
);
}
return CustomSchemeResponse(
contentType: fontResult.$2,
data: fontResult.$1,
);
}
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
if (result == null) {
return CustomSchemeResponse(
contentType: 'text/plain',
data: Uint8List.fromList('File not found'.codeUnits),
);
}
return CustomSchemeResponse(
contentType: result.$2,
data: result.$1,
);
} catch (e) {
return CustomSchemeResponse(
contentType: 'text/plain',
data: Uint8List.fromList('Error reading file: $e'.codeUnits),
);
}
}
/// Read a file from an EPUB.
/// Returns (data, mimeType) or null on failure.
Future<(Uint8List, String)?> _readFileFromEpub(
String epubPath,
String fileHash,
WebUri requestUrl,
) async {
final prefix = "/book/$fileHash/";
if (!requestUrl.path.startsWith(prefix)) {
return null;
}
final decodedPath = Uri.decodeFull(requestUrl.path);
final relativePath = decodedPath.substring(prefix.length);
final fileRelativePath = relativePath.split('#')[0];
final data = await _streamService.readFileFromEpub(
epubPath: epubPath,
targetFilePath: fileRelativePath,
);
if (data == null) return null;
final mimeType = _streamService.getMimeType(fileRelativePath);
return (data, mimeType);
}
/// Reads a font file from the app's fonts directory.
/// URL format: epub://localhost/fonts/{fileName}
Future<(Uint8List, String)?> _readFontFile(WebUri requestUrl) async {
const prefix = '/fonts/';
if (!requestUrl.path.startsWith(prefix)) {
return null;
}
final fileName = Uri.decodeComponent(
requestUrl.path.substring(prefix.length),
);
if (fileName.isEmpty || fileName.contains('/')) {
return null;
}
final documentsPath = await getDocumentsPath();
final filePath = '${documentsPath}fonts/$fileName';
final file = File(filePath);
if (!await file.exists()) {
return null;
}
final bytes = await file.readAsBytes();
final ext = fileName.toLowerCase().split('.').last;
final mimeType = _fontMimeTypes[ext] ?? 'application/octet-stream';
return (bytes, mimeType);
}
static const _fontMimeTypes = {
'ttf': 'font/ttf',
'otf': 'font/otf',
'woff': 'font/woff',
'woff2': 'font/woff2',
};
/// Generate base URL for a chapter.
/// This URL should be used as the baseUrl parameter when loading HTML.
static String getBaseUrl() {
return '$virtualScheme://$virtualDomain/book/index.html';
}
/// Generate full URL for a specific file.
static String getFileUrl(String fileHash, Href href) {
final url =
'$virtualScheme://$virtualDomain/book/$fileHash/${href.path}${'#${href.anchor}'}';
return Uri.encodeFull(url);
}
/// Generate URL for a user-imported font file.
/// Format: epub://localhost/fonts/{fileName}
static String getFontUrl(String fileName) {
return '$virtualScheme://$virtualDomain/fonts/$fileName';
}
/// Check if a request is for an EPUB file.
static bool isEpubRequest(WebUri requestUrl) {
return requestUrl.scheme == virtualScheme &&
requestUrl.host == virtualDomain &&
requestUrl.path.startsWith('/book/');
}
/// Resolve image bytes from an EPUB for the image viewer.
/// The imageUrl may be a virtual epub:// URL or a relative path.
Future<Uint8List?> resolveImageFromEpub({
required String epubPath,
required String imageUrl,
required String fileHash,
}) async {
try {
String relativePath;
if (imageUrl.startsWith(virtualScheme)) {
final uri = Uri.parse(imageUrl);
final prefix = '/book/$fileHash/';
if (!uri.path.startsWith(prefix)) return null;
relativePath = Uri.decodeFull(uri.path).substring(prefix.length);
} else {
relativePath = imageUrl;
}
relativePath = relativePath.split('#')[0];
return await _streamService.readFileFromEpub(
epubPath: epubPath,
targetFilePath: relativePath,
);
} catch (_) {
return null;
}
}
/// Check if a request is for a user-imported font.
static bool isFontRequest(WebUri requestUrl) {
return requestUrl.scheme == virtualScheme &&
requestUrl.host == virtualDomain &&
requestUrl.path.startsWith('/fonts/');
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'epub_theme.dart';
/// Controls how the reader handles external link taps.
enum ReaderLinkHandling { ask, always, never }
/// Controls the page-turning animation style.
enum ReaderPageAnimation { none, slide }
class ReaderSettings {
final double zoom;
final bool followAppTheme;
final double marginTop;
final double marginBottom;
final double marginLeft;
final double marginRight;
final ReaderLinkHandling linkHandling;
final ReaderPageAnimation pageAnimation;
/// File name (with extension) of the user-imported font to use, or null to
/// use the epub's own fonts.
final String? fontFileName;
/// When true the custom font overrides the epub's own font-family rules.
final bool overrideFontFamily;
/// When true, volume up/down keys turn pages in the reader.
final bool volumeKeyTurnsPage;
/// Reader theme preset index (0 = follow app, 1-8 = presets, 9 = custom).
final int themeIndex;
/// Custom background color (ARGB int), used when themeIndex == 9.
final int customBgColor;
/// Custom text color (ARGB int), used when themeIndex == 9.
final int customTextColor;
const ReaderSettings({
this.zoom = 1.0,
this.followAppTheme = true,
this.marginTop = 16.0,
this.marginBottom = 16.0,
this.marginLeft = 16.0,
this.marginRight = 16.0,
this.linkHandling = ReaderLinkHandling.ask,
this.pageAnimation = ReaderPageAnimation.slide,
this.fontFileName,
this.overrideFontFamily = false,
this.volumeKeyTurnsPage = false,
this.themeIndex = 0,
this.customBgColor = 0xFFFFFFFF,
this.customTextColor = 0xFF1A1A1A,
});
// Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than
// "leave unchanged". Used only for the nullable fontFileName field.
static const Object _kUnset = Object();
ReaderSettings copyWith({
double? zoom,
bool? followAppTheme,
double? marginTop,
double? marginBottom,
double? marginLeft,
double? marginRight,
ReaderLinkHandling? linkHandling,
ReaderPageAnimation? pageAnimation,
Object? fontFileName = _kUnset,
bool? overrideFontFamily,
bool? volumeKeyTurnsPage,
int? themeIndex,
int? customBgColor,
int? customTextColor,
}) {
return ReaderSettings(
zoom: zoom ?? this.zoom,
followAppTheme: followAppTheme ?? this.followAppTheme,
marginTop: marginTop ?? this.marginTop,
marginBottom: marginBottom ?? this.marginBottom,
marginLeft: marginLeft ?? this.marginLeft,
marginRight: marginRight ?? this.marginRight,
linkHandling: linkHandling ?? this.linkHandling,
pageAnimation: pageAnimation ?? this.pageAnimation,
fontFileName: identical(fontFileName, _kUnset)
? this.fontFileName
: fontFileName as String?,
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage,
themeIndex: themeIndex ?? this.themeIndex,
customBgColor: customBgColor ?? this.customBgColor,
customTextColor: customTextColor ?? this.customTextColor,
);
}
EpubTheme toEpubTheme(BuildContext context) {
ColorScheme colorScheme;
bool shouldOverride = true;
if (themeIndex == 0) {
// 跟随 App 主题
colorScheme = Theme.of(context).colorScheme;
} else {
Color bg;
Color text;
bool isDark;
if (themeIndex == 9) {
// 自定义颜色
bg = Color(customBgColor);
text = Color(customTextColor);
isDark = ThemeData.estimateBrightnessForColor(bg) == Brightness.dark;
} else if (themeIndex >= 1 &&
themeIndex <= ReaderThemePresets.presets.length) {
final preset = ReaderThemePresets.presets[themeIndex];
bg = preset.surface;
text = preset.onSurface;
isDark = preset.isDark;
} else {
colorScheme = Theme.of(context).colorScheme;
return EpubTheme(
zoom: zoom,
shouldOverrideTextColor: true,
colorScheme: colorScheme,
padding: EdgeInsets.only(
top: marginTop, bottom: marginBottom,
left: marginLeft, right: marginRight,
),
fontFileName: fontFileName,
overrideFontFamily: overrideFontFamily,
);
}
colorScheme = ColorScheme(
brightness: isDark ? Brightness.dark : Brightness.light,
primary: text,
onPrimary: bg,
secondary: text,
onSecondary: bg,
error: const Color(0xFFDC2626),
onError: bg,
surface: bg,
onSurface: text,
surfaceContainerHighest: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
surfaceContainerHigh: isDark ? const Color(0xFF222222) : const Color(0xFFFAFAFA),
surfaceContainer: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
surfaceContainerLow: isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA),
outline: isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC),
outlineVariant: isDark ? const Color(0xFF333333) : const Color(0xFFE5E5E5),
onSurfaceVariant: isDark ? const Color(0xFFAAAAAA) : const Color(0xFF666666),
primaryContainer: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
);
}
return EpubTheme(
zoom: zoom,
shouldOverrideTextColor: shouldOverride,
colorScheme: colorScheme,
padding: EdgeInsets.only(
top: marginTop, bottom: marginBottom,
left: marginLeft, right: marginRight,
),
fontFileName: fontFileName,
overrideFontFamily: overrideFontFamily,
);
}
// ==================== Persistence ====================
static const _kPrefix = 'reader_';
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('${_kPrefix}zoom', zoom);
await prefs.setBool('${_kPrefix}followAppTheme', followAppTheme);
await prefs.setDouble('${_kPrefix}marginTop', marginTop);
await prefs.setDouble('${_kPrefix}marginBottom', marginBottom);
await prefs.setDouble('${_kPrefix}marginLeft', marginLeft);
await prefs.setDouble('${_kPrefix}marginRight', marginRight);
await prefs.setInt('${_kPrefix}linkHandling', linkHandling.index);
await prefs.setInt('${_kPrefix}pageAnimation', pageAnimation.index);
if (fontFileName != null) {
await prefs.setString('${_kPrefix}fontFileName', fontFileName!);
} else {
await prefs.remove('${_kPrefix}fontFileName');
}
await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily);
await prefs.setBool('${_kPrefix}volumeKeyTurnsPage', volumeKeyTurnsPage);
await prefs.setInt('${_kPrefix}themeIndex', themeIndex);
await prefs.setInt('${_kPrefix}customBgColor', customBgColor);
await prefs.setInt('${_kPrefix}customTextColor', customTextColor);
}
static Future<ReaderSettings> load() async {
final prefs = await SharedPreferences.getInstance();
return ReaderSettings(
zoom: prefs.getDouble('${_kPrefix}zoom') ?? 1.0,
followAppTheme: prefs.getBool('${_kPrefix}followAppTheme') ?? true,
marginTop: prefs.getDouble('${_kPrefix}marginTop') ?? 16.0,
marginBottom: prefs.getDouble('${_kPrefix}marginBottom') ?? 16.0,
marginLeft: prefs.getDouble('${_kPrefix}marginLeft') ?? 16.0,
marginRight: prefs.getDouble('${_kPrefix}marginRight') ?? 16.0,
linkHandling: ReaderLinkHandling.values[
prefs.getInt('${_kPrefix}linkHandling') ??
ReaderLinkHandling.ask.index],
pageAnimation: ReaderPageAnimation.values[
prefs.getInt('${_kPrefix}pageAnimation') ??
ReaderPageAnimation.slide.index],
fontFileName: prefs.getString('${_kPrefix}fontFileName'),
overrideFontFamily:
prefs.getBool('${_kPrefix}overrideFontFamily') ?? false,
volumeKeyTurnsPage:
prefs.getBool('${_kPrefix}volumeKeyTurnsPage') ?? false,
);
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:convert';
import 'webview_bridge.dart';
/// Typed Dart mirror of the TypeScript `ReaderApi` interface
/// (`web_assets/controller.js/api.ts`).
///
/// Every public method corresponds 1-to-1 with its TypeScript counterpart.
/// The token parameter is managed internally by [WebViewBridge] — callers
/// never touch raw token integers through this class.
///
/// Methods that return `Future<int>` fire the JS call and return a token the
/// caller can later pass to [WebViewBridge.waitForEvent] / [waitForEvents]
/// when it wants to batch-await multiple operations together.
///
/// Methods that return `Future<void>` fire the JS call and await its
/// completion before returning.
class ReaderApi {
final WebViewBridge _bridge;
ReaderApi(this._bridge);
// ─── Token-based (deferred await) ──────────────────────────────────
/// Loads [url] into the iframe identified by [slot].
/// [anchors] should be a JSON-encoded list: `'["id1","id2"]'`.
Future<int> loadFrame(
String slot,
String url,
String anchors,
String properties,
) => _bridge.call(
(t) => "window.api.loadFrame($t, '$slot', '$url', $anchors, $properties)",
);
/// Scrolls [slot]'s iframe to [pageIndex] without immediately awaiting.
Future<int> jumpToPageFor(String slot, int pageIndex) =>
_bridge.call((t) => "window.api.jumpToPageFor($t, '$slot', $pageIndex)");
/// Scrolls [slot]'s iframe to its last page without immediately awaiting.
Future<int> jumpToLastPageOfFrame(String slot) =>
_bridge.call((t) => "window.api.jumpToLastPageOfFrame($t, '$slot')");
/// Rotates the iframe triple in [direction] (`'next'` or `'prev'`).
Future<int> cycleFrames(String direction) =>
_bridge.call((t) => "window.api.cycleFrames($t, '$direction')");
// ─── Fire-and-await ────────────────────────────────────────────────
/// Scrolls the current iframe to [pageIndex] and awaits completion.
Future<void> jumpToPage(int pageIndex) =>
_bridge.callAndWait((t) => 'window.api.jumpToPage($t, $pageIndex)', 1000);
/// Restores the scroll position using a fractional [ratio] in [0,1].
Future<void> restoreScrollPosition(double ratio) => _bridge.callAndWait(
(t) => 'window.api.restoreScrollPosition($t, $ratio)',
1000,
);
/// Waits for the current frame to finish rendering.
Future<void> waitForRender() =>
_bridge.callAndWait((t) => 'window.api.waitForRender($t)', 1000);
/// Updates the reader theme/layout and awaits completion.
///
/// [theme] must be a JSON-serialisable map produced by `EpubTheme.toMap()`.
Future<void> updateTheme(
double viewWidth,
double viewHeight,
Map<String, dynamic> theme,
) {
final themeJson = jsonEncode(theme);
return _bridge.callAndWait(
(t) => 'window.api.updateTheme($t, $viewWidth, $viewHeight, $themeJson)',
);
}
// ─── Fire-and-forget ───────────────────────────────────────────────
/// Checks whether there is an interactive element (image, etc.) at (x, y).
Future<void> checkLongPressElementAt(double x, double y) =>
_bridge.evaluate('window.api.checkLongPressElementAt($x, $y)');
/// Checks whether the tap at (x, y) hits a link, footnote, or other element.
Future<void> checkTapElementAt(double x, double y) =>
_bridge.evaluate('window.api.checkTapElementAt($x, $y)');
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Manages JS↔Dart communication over an [InAppWebViewController].
///
/// Provides token-based async call tracking so that callers can fire a JS
/// method that will eventually invoke `FlutterBridge.onEventFinished(token)`,
/// and await the result on the Dart side via [waitForEvent].
///
/// Typical usage:
/// ```dart
/// // Fire and forget the token; caller awaits separately.
/// final token = await _bridge.call((t) => "window.api.loadFrame($t, ...)");
/// await _bridge.waitForEvent(token);
///
/// // Fire and immediately await.
/// await _bridge.callAndWait((t) => "window.api.jumpToPage($t, $idx)");
/// ```
class WebViewBridge {
InAppWebViewController? _controller;
int _currentToken = 0;
final Map<int, Completer<void>> _completers = {};
// ─── Controller lifecycle ──────────────────────────────────────────
/// Attaches a live [InAppWebViewController]. Call this in `onWebViewCreated`.
void attach(InAppWebViewController controller) {
_controller = controller;
}
/// Detaches the controller and cancels all pending completers.
void detach() {
_controller = null;
for (final completer in _completers.values) {
if (!completer.isCompleted) {
completer.completeError(StateError('WebViewBridge detached'));
}
}
_completers.clear();
}
// ─── JS evaluation ─────────────────────────────────────────────────
/// Evaluates [source] in the WebView. No-ops if no controller is attached.
Future<void> evaluate(String source) async {
await _controller?.evaluateJavascript(source: source);
}
// ─── Token management ──────────────────────────────────────────────
/// Allocates a new token and registers a [Completer] for it.
///
/// Embed the returned token in the JS call so JS can resolve it via
/// `FlutterBridge.onEventFinished(token)`.
int issueToken() {
_currentToken++;
_completers[_currentToken] = Completer<void>();
return _currentToken;
}
/// Called by the `onEventFinished` JS handler to resolve a pending token.
///
/// A [token] of `-1` is a sentinel for fire-and-forget notifications that
/// do not need to be tracked.
void resolveToken(int token) {
if (token == -1) return;
final completer = _completers.remove(token);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
// ─── Awaiting ──────────────────────────────────────────────────────
/// Waits for [token] to be resolved, or times out after [timeoutMs] ms.
Future<void> waitForEvent(int token, [int timeoutMs = 10000]) async {
final completer = _completers[token];
if (completer == null) {
debugPrint('WebViewBridge: no completer for token $token');
return;
}
return completer.future.timeout(
Duration(milliseconds: timeoutMs),
onTimeout: () {
_completers.remove(token);
debugPrint('WebViewBridge: timeout for token $token');
},
);
}
/// Waits for all [tokens] to be resolved concurrently.
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
await Future.wait(tokens.map((t) => waitForEvent(t, timeoutMs)));
}
// ─── Convenience helpers ───────────────────────────────────────────
/// Issues a token, evaluates the JS returned by [source], and returns the
/// token so the caller can [waitForEvent] later.
Future<int> call(String Function(int token) source) async {
final token = issueToken();
await evaluate(source(token));
return token;
}
/// Issues a token, evaluates the JS returned by [source], and immediately
/// awaits [waitForEvent] before returning.
Future<void> callAndWait(
String Function(int token) source, [
int timeoutMs = 10000,
]) async {
final token = issueToken();
await evaluate(source(token));
await waitForEvent(token, timeoutMs);
}
}