Initial commit
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../contracts/danmaku.dart';
|
||||
import '../../../contracts/error.dart';
|
||||
import '../../../contracts/script_widget.dart';
|
||||
import '../../../../shared/utils/app_logger.dart';
|
||||
import '../../../../shared/utils/async_lock.dart';
|
||||
import '../../errors.dart';
|
||||
import '../../ports/danmaku_sources_store.dart';
|
||||
import '../../ports/script_widget_remote_gateway.dart';
|
||||
import '../../ports/script_widget_repository.dart';
|
||||
import '../../ports/script_widget_runtime.dart';
|
||||
|
||||
const String scriptWidgetHostVersion = '0.0.2';
|
||||
|
||||
class ScriptWidgetContentSection {
|
||||
final String title;
|
||||
final List<ScriptVideoItem> items;
|
||||
|
||||
const ScriptWidgetContentSection({required this.title, required this.items});
|
||||
}
|
||||
|
||||
class ScriptWidgetService {
|
||||
static const String _tag = 'ScriptWidgetService';
|
||||
|
||||
final ScriptWidgetRepository repository;
|
||||
final ScriptWidgetRemoteGateway remoteGateway;
|
||||
final ScriptWidgetRuntime runtime;
|
||||
final DanmakuSourcesStore? danmakuSourcesStore;
|
||||
final Future<void> Function()? onDanmakuSourcesChanged;
|
||||
final Map<String, AsyncLock> _widgetLocks = <String, AsyncLock>{};
|
||||
|
||||
ScriptWidgetService({
|
||||
required this.repository,
|
||||
required this.remoteGateway,
|
||||
required this.runtime,
|
||||
this.danmakuSourcesStore,
|
||||
this.onDanmakuSourcesChanged,
|
||||
});
|
||||
|
||||
Future<List<InstalledScriptWidget>> listWidgets() => repository.listWidgets();
|
||||
|
||||
Future<List<ScriptWidgetSubscription>> listSubscriptions() =>
|
||||
repository.listSubscriptions();
|
||||
|
||||
Future<InstalledScriptWidget> requireWidget(String widgetId) async {
|
||||
final widget = await repository.findWidget(widgetId);
|
||||
if (widget == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '未找到模块:$widgetId');
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> installFromUrl(String url) async {
|
||||
final scriptSource = await remoteGateway.fetchText(url);
|
||||
return installFromSource(scriptSource, sourceUrl: url);
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> installFromSource(
|
||||
String scriptSource, {
|
||||
String sourceUrl = '',
|
||||
}) async {
|
||||
final manifest = await runtime.inspectWidget(scriptSource);
|
||||
_assertCompatible(manifest);
|
||||
return _runWidgetOperation(
|
||||
manifest.id,
|
||||
() =>
|
||||
_installInspectedWidget(manifest, scriptSource, sourceUrl: sourceUrl),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ScriptWidgetSubscription> importSubscription(String url) async {
|
||||
final rawCatalog = await remoteGateway.fetchText(url);
|
||||
return importSubscriptionSource(rawCatalog, sourceId: url);
|
||||
}
|
||||
|
||||
Future<ScriptWidgetSubscription> importSubscriptionSource(
|
||||
String rawCatalog, {
|
||||
required String sourceId,
|
||||
}) async {
|
||||
final decoded = jsonDecode(rawCatalog);
|
||||
if (decoded is! Map) {
|
||||
throw DomainError(ErrorCode.invalidParams, '订阅文件不是有效的 JSON 对象');
|
||||
}
|
||||
final decodedCatalog = ScriptWidgetCatalog.fromJson(
|
||||
Map<String, dynamic>.from(decoded),
|
||||
);
|
||||
if (decodedCatalog.title.trim().isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '订阅缺少 title');
|
||||
}
|
||||
final catalog = decodedCatalog.copyWith(
|
||||
widgets: decodedCatalog.widgets
|
||||
.map((entry) => _resolveCatalogEntryUrl(entry, sourceId))
|
||||
.toList(growable: false),
|
||||
);
|
||||
final subscription = ScriptWidgetSubscription(
|
||||
url: sourceId,
|
||||
catalog: catalog,
|
||||
refreshedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await repository.upsertSubscription(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> refreshWidget(String widgetId) async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
if (widget.sourceUrl.trim().isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '本地导入模块没有可用的更新地址');
|
||||
}
|
||||
final scriptSource = await remoteGateway.fetchText(widget.sourceUrl);
|
||||
final manifest = await runtime.inspectWidget(scriptSource);
|
||||
if (manifest.id != widgetId) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块更新的 ID 不匹配:期望 $widgetId,实际为 ${manifest.id}',
|
||||
);
|
||||
}
|
||||
_assertCompatible(manifest);
|
||||
return _runWidgetOperation(
|
||||
widgetId,
|
||||
() => _installInspectedWidget(
|
||||
manifest,
|
||||
scriptSource,
|
||||
sourceUrl: widget.sourceUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setWidgetEnabled(String widgetId, bool enabled) async {
|
||||
await _runWidgetOperation(widgetId, () async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
final updated = widget.copyWith(
|
||||
enabled: enabled,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await repository.upsertWidget(updated);
|
||||
await _synchronizeDanmakuSource(updated);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateGlobalParams(
|
||||
String widgetId,
|
||||
Map<String, Object?> globalParams,
|
||||
) async {
|
||||
await _runWidgetOperation(widgetId, () async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
await repository.upsertWidget(
|
||||
widget.copyWith(
|
||||
globalParams: Map<String, Object?>.unmodifiable(globalParams),
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteWidget(String widgetId) async {
|
||||
await _runWidgetOperation(widgetId, () async {
|
||||
await repository.deleteWidget(widgetId);
|
||||
await runtime.unloadWidget(widgetId);
|
||||
final sourceStore = danmakuSourcesStore;
|
||||
if (sourceStore != null) {
|
||||
final sources = await sourceStore.list();
|
||||
await sourceStore.replaceAll(
|
||||
sources.where((source) => source.url != 'jsw://$widgetId').toList(),
|
||||
);
|
||||
await onDanmakuSourcesChanged?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Object?> invoke(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
return _runWidgetOperation(widgetId, () async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
if (!widget.enabled) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块已停用:${widget.manifest.title}',
|
||||
);
|
||||
}
|
||||
await _ensureLoaded(widget);
|
||||
final rawResult = await runtime.invokeModuleFunction(
|
||||
widgetId,
|
||||
functionName,
|
||||
<String, Object?>{...widget.globalParams, ...params},
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'[$widgetId] $functionName -> ${_summarizeInvocationResult(rawResult)}',
|
||||
);
|
||||
return rawResult;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<ScriptWidgetContentSection>> loadModuleSections(
|
||||
String widgetId,
|
||||
ScriptWidgetModule module,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final resolvedParams = <String, Object?>{
|
||||
...buildScriptWidgetParameterDefaults(module.params),
|
||||
...params,
|
||||
};
|
||||
final rawResult = await invoke(
|
||||
widgetId,
|
||||
module.functionName,
|
||||
resolvedParams,
|
||||
);
|
||||
return decodeVideoSections(
|
||||
rawResult,
|
||||
fallbackTitle: module.title,
|
||||
preserveSections: module.sectionMode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ScriptVideoItem>> search(
|
||||
String widgetId,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
final searchModule = widget.manifest.search;
|
||||
if (searchModule == null) return const <ScriptVideoItem>[];
|
||||
final rawResult = await invoke(widgetId, searchModule.functionName, params);
|
||||
return decodeVideoItems(rawResult);
|
||||
}
|
||||
|
||||
Future<ScriptVideoItem?> loadDetail(String widgetId, String link) async {
|
||||
final rawResult = await invoke(widgetId, 'loadDetail', <String, Object?>{
|
||||
'__forwardRawArgument': link,
|
||||
'link': link,
|
||||
});
|
||||
final items = decodeVideoItems(rawResult);
|
||||
return items.isEmpty ? null : items.first;
|
||||
}
|
||||
|
||||
Future<List<ScriptVideoResource>> loadResources(
|
||||
String widgetId,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final widget = await requireWidget(widgetId);
|
||||
final resourceModule = widget.manifest.modules
|
||||
.where(
|
||||
(module) => module.id == 'loadResource' || module.type == 'stream',
|
||||
)
|
||||
.firstOrNull;
|
||||
if (resourceModule == null) return const <ScriptVideoResource>[];
|
||||
final rawResult = await invoke(
|
||||
widgetId,
|
||||
resourceModule.functionName,
|
||||
params,
|
||||
);
|
||||
return _decodeMapList(rawResult)
|
||||
.map(ScriptVideoResource.fromJson)
|
||||
.where((resource) => resource.url.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _ensureLoaded(InstalledScriptWidget widget) async {
|
||||
final widgetId = widget.manifest.id;
|
||||
if (runtime.isWidgetLoaded(widgetId)) return;
|
||||
final loadedManifest = await runtime.loadWidget(widget.scriptSource);
|
||||
if (loadedManifest.id != widgetId) {
|
||||
await runtime.unloadWidget(loadedManifest.id);
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'已安装模块的脚本 ID 不匹配:期望 $widgetId,实际为 ${loadedManifest.id}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<InstalledScriptWidget> _installInspectedWidget(
|
||||
ScriptWidgetManifest manifest,
|
||||
String scriptSource, {
|
||||
required String sourceUrl,
|
||||
}) async {
|
||||
final existingWidget = await repository.findWidget(manifest.id);
|
||||
final existingDanmakuSources = danmakuSourcesStore == null
|
||||
? null
|
||||
: await danmakuSourcesStore!.list();
|
||||
final now = DateTime.now().toUtc();
|
||||
final globalParams = <String, Object?>{
|
||||
for (final parameter in manifest.globalParams)
|
||||
parameter.name:
|
||||
existingWidget?.globalParams[parameter.name] ?? parameter.value,
|
||||
};
|
||||
final installedWidget = InstalledScriptWidget(
|
||||
manifest: manifest,
|
||||
scriptSource: scriptSource,
|
||||
sourceUrl: sourceUrl,
|
||||
enabled: existingWidget?.enabled ?? true,
|
||||
globalParams: globalParams,
|
||||
installedAt: existingWidget?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
try {
|
||||
final loadedManifest = await runtime.loadWidget(scriptSource);
|
||||
if (loadedManifest.id != manifest.id) {
|
||||
await runtime.unloadWidget(loadedManifest.id);
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块脚本在检查与加载期间返回了不同的 ID',
|
||||
details: <String, Object?>{
|
||||
'inspectedId': manifest.id,
|
||||
'loadedId': loadedManifest.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
await repository.upsertWidget(installedWidget);
|
||||
await _synchronizeDanmakuSource(installedWidget);
|
||||
return installedWidget;
|
||||
} catch (error, stackTrace) {
|
||||
await _restoreInstallation(
|
||||
manifest.id,
|
||||
existingWidget,
|
||||
existingDanmakuSources,
|
||||
);
|
||||
Error.throwWithStackTrace(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restoreInstallation(
|
||||
String widgetId,
|
||||
InstalledScriptWidget? existingWidget,
|
||||
List<DanmakuSource>? existingDanmakuSources,
|
||||
) async {
|
||||
await _ignoreRollbackFailure(() async {
|
||||
if (existingWidget == null) {
|
||||
await repository.deleteWidget(widgetId);
|
||||
} else {
|
||||
await repository.upsertWidget(existingWidget);
|
||||
}
|
||||
});
|
||||
await _ignoreRollbackFailure(() async {
|
||||
if (existingWidget == null) {
|
||||
await runtime.unloadWidget(widgetId);
|
||||
} else {
|
||||
var restoredExistingRuntime = false;
|
||||
try {
|
||||
final restoredManifest = await runtime.loadWidget(
|
||||
existingWidget.scriptSource,
|
||||
);
|
||||
restoredExistingRuntime = restoredManifest.id == widgetId;
|
||||
if (!restoredExistingRuntime) {
|
||||
await runtime.unloadWidget(restoredManifest.id);
|
||||
}
|
||||
} finally {
|
||||
if (!restoredExistingRuntime) {
|
||||
await runtime.unloadWidget(widgetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
final sourceStore = danmakuSourcesStore;
|
||||
if (sourceStore != null && existingDanmakuSources != null) {
|
||||
await _ignoreRollbackFailure(() async {
|
||||
await sourceStore.replaceAll(existingDanmakuSources);
|
||||
await onDanmakuSourcesChanged?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _runWidgetOperation<T>(
|
||||
String widgetId,
|
||||
Future<T> Function() operation,
|
||||
) {
|
||||
|
||||
|
||||
return _widgetLocks.putIfAbsent(widgetId, AsyncLock.new).run(operation);
|
||||
}
|
||||
|
||||
Future<void> _ignoreRollbackFailure(Future<void> Function() rollback) async {
|
||||
try {
|
||||
await rollback();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
ScriptWidgetCatalogEntry _resolveCatalogEntryUrl(
|
||||
ScriptWidgetCatalogEntry entry,
|
||||
String sourceId,
|
||||
) {
|
||||
final entryUri = Uri.tryParse(entry.url);
|
||||
if (entryUri == null || entryUri.hasScheme) return entry;
|
||||
final sourceUri = Uri.tryParse(sourceId);
|
||||
if (sourceUri == null || !sourceUri.hasScheme) return entry;
|
||||
return entry.copyWith(url: sourceUri.resolveUri(entryUri).toString());
|
||||
}
|
||||
|
||||
void _assertCompatible(ScriptWidgetManifest manifest) {
|
||||
if (_compareVersions(manifest.requiredVersion, scriptWidgetHostVersion) >
|
||||
0) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块需要 ForwardWidgets ${manifest.requiredVersion},当前兼容版本为 $scriptWidgetHostVersion',
|
||||
);
|
||||
}
|
||||
if (manifest.modules.any((module) => module.requiresWebView)) {
|
||||
throw DomainError(ErrorCode.invalidParams, '当前版本暂不支持 requiresWebView 模块');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _synchronizeDanmakuSource(InstalledScriptWidget widget) async {
|
||||
final sourceStore = danmakuSourcesStore;
|
||||
if (sourceStore == null) return;
|
||||
final hasDanmaku = widget.manifest.modules.any(
|
||||
(module) => module.type == 'danmu',
|
||||
);
|
||||
final sourceUrl = 'jsw://${widget.manifest.id}';
|
||||
final sources = await sourceStore.list();
|
||||
sources.removeWhere((source) => source.url == sourceUrl);
|
||||
if (hasDanmaku) {
|
||||
sources.add(
|
||||
DanmakuSource(
|
||||
id: 'script-widget-${widget.manifest.id}',
|
||||
name: widget.manifest.title,
|
||||
url: sourceUrl,
|
||||
enabled: widget.enabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
await sourceStore.replaceAll(sources);
|
||||
await onDanmakuSourcesChanged?.call();
|
||||
}
|
||||
}
|
||||
|
||||
List<ScriptVideoItem> decodeVideoItems(Object? rawResult) {
|
||||
final maps = _decodeMapList(rawResult);
|
||||
if (maps.isEmpty) {
|
||||
if (_isExplicitlyEmptyModuleResult(rawResult)) {
|
||||
return const <ScriptVideoItem>[];
|
||||
}
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块返回了无法识别的内容格式',
|
||||
details: <String, Object?>{
|
||||
'resultType': rawResult.runtimeType.toString(),
|
||||
'result': _summarizeModuleResult(rawResult),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final items = <ScriptVideoItem>[];
|
||||
final decodingErrors = <String>[];
|
||||
for (final map in maps) {
|
||||
if (map['items'] is List && !map.containsKey('id')) {
|
||||
try {
|
||||
items.addAll(decodeVideoItems(map['items']));
|
||||
} catch (error) {
|
||||
decodingErrors.add(error.toString());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
items.add(ScriptVideoItem.fromJson(_normalizeVideoItemMap(map)));
|
||||
} catch (error) {
|
||||
decodingErrors.add(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty && decodingErrors.isNotEmpty) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块返回了内容,但字段格式与 ForwardWidgets 契约不兼容',
|
||||
details: <String, Object?>{
|
||||
'itemCount': maps.length,
|
||||
'firstItem': _summarizeModuleResult(maps.first),
|
||||
'firstError': decodingErrors.first,
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeVideoItemMap(Map<String, dynamic> source) {
|
||||
final normalized = Map<String, dynamic>.from(source);
|
||||
final fallbackId =
|
||||
normalized['link'] ??
|
||||
normalized['videoUrl'] ??
|
||||
normalized['url'] ??
|
||||
normalized['title'];
|
||||
normalized['id'] = normalized['id'] ?? fallbackId ?? '';
|
||||
const stringFieldNames = <String>{
|
||||
'id',
|
||||
'type',
|
||||
'title',
|
||||
'coverUrl',
|
||||
'posterPath',
|
||||
'posterUrl',
|
||||
'poster_url',
|
||||
'detailPoster',
|
||||
'backdropPath',
|
||||
'releaseDate',
|
||||
'mediaType',
|
||||
'genreTitle',
|
||||
'durationText',
|
||||
'previewUrl',
|
||||
'videoUrl',
|
||||
'link',
|
||||
'description',
|
||||
'playerType',
|
||||
};
|
||||
for (final fieldName in stringFieldNames) {
|
||||
final value = normalized[fieldName];
|
||||
if (value != null) normalized[fieldName] = value.toString();
|
||||
}
|
||||
normalized['title'] ??= '';
|
||||
normalized['type'] ??= 'link';
|
||||
|
||||
final subtitle = normalized['subTitle'] ?? normalized['subtitle'];
|
||||
if (subtitle != null &&
|
||||
(normalized['description'] == null ||
|
||||
normalized['description'].toString().isEmpty)) {
|
||||
normalized['description'] = subtitle.toString();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
bool _isExplicitlyEmptyModuleResult(Object? rawResult) {
|
||||
if (rawResult is List) return rawResult.isEmpty;
|
||||
if (rawResult is Map) return rawResult.isEmpty;
|
||||
return false;
|
||||
}
|
||||
|
||||
String _summarizeModuleResult(Object? rawResult) {
|
||||
final summary = rawResult?.toString() ?? 'null';
|
||||
const maximumSummaryLength = 500;
|
||||
if (summary.length <= maximumSummaryLength) return summary;
|
||||
return '${summary.substring(0, maximumSummaryLength)}…';
|
||||
}
|
||||
|
||||
List<ScriptWidgetContentSection> decodeVideoSections(
|
||||
Object? rawResult, {
|
||||
required String fallbackTitle,
|
||||
required bool preserveSections,
|
||||
}) {
|
||||
if (preserveSections && rawResult is List) {
|
||||
final sections = <ScriptWidgetContentSection>[];
|
||||
for (final rawSection in rawResult.whereType<Map>()) {
|
||||
final section = Map<String, dynamic>.from(rawSection);
|
||||
final rawItems =
|
||||
section['items'] ?? section['data'] ?? section['results'];
|
||||
if (rawItems == null) continue;
|
||||
final items = decodeVideoItems(rawItems);
|
||||
if (items.isEmpty) continue;
|
||||
sections.add(
|
||||
ScriptWidgetContentSection(
|
||||
title:
|
||||
section['title']?.toString() ??
|
||||
section['name']?.toString() ??
|
||||
fallbackTitle,
|
||||
items: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sections.isNotEmpty) return sections;
|
||||
}
|
||||
|
||||
final items = decodeVideoItems(rawResult);
|
||||
if (items.isEmpty) return const <ScriptWidgetContentSection>[];
|
||||
return <ScriptWidgetContentSection>[
|
||||
ScriptWidgetContentSection(title: fallbackTitle, items: items),
|
||||
];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _decodeMapList(Object? rawResult) {
|
||||
if (rawResult is String) {
|
||||
final trimmedResult = rawResult.trim();
|
||||
if (trimmedResult.isEmpty) return const <Map<String, dynamic>>[];
|
||||
try {
|
||||
return _decodeMapList(jsonDecode(trimmedResult));
|
||||
} catch (_) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
}
|
||||
if (rawResult is Map) {
|
||||
final map = Map<String, dynamic>.from(rawResult);
|
||||
final nested = map['items'] ?? map['results'] ?? map['data'];
|
||||
if (!map.containsKey('id') && nested != null) {
|
||||
return _decodeMapList(nested);
|
||||
}
|
||||
return <Map<String, dynamic>>[map];
|
||||
}
|
||||
if (rawResult is! List) return const <Map<String, dynamic>>[];
|
||||
return rawResult
|
||||
.whereType<Map>()
|
||||
.map(Map<String, dynamic>.from)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
int _compareVersions(String left, String right) {
|
||||
final leftParts = _versionParts(left);
|
||||
final rightParts = _versionParts(right);
|
||||
final length = leftParts.length > rightParts.length
|
||||
? leftParts.length
|
||||
: rightParts.length;
|
||||
for (var index = 0; index < length; index++) {
|
||||
final leftValue = index < leftParts.length ? leftParts[index] : 0;
|
||||
final rightValue = index < rightParts.length ? rightParts[index] : 0;
|
||||
if (leftValue != rightValue) return leftValue.compareTo(rightValue);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<int> _versionParts(String version) {
|
||||
return version
|
||||
.split('.')
|
||||
.map((part) => int.tryParse(RegExp(r'\d+').stringMatch(part) ?? '') ?? 0)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _summarizeInvocationResult(Object? rawResult) {
|
||||
if (rawResult == null) return 'null';
|
||||
if (rawResult is List) {
|
||||
return 'List(length=${rawResult.length})';
|
||||
}
|
||||
if (rawResult is Map) {
|
||||
return 'Map(keys=${rawResult.keys.take(6).toList()}, size=${rawResult.length})';
|
||||
}
|
||||
final summary = rawResult.toString();
|
||||
const maximumSummaryLength = 200;
|
||||
if (summary.length <= maximumSummaryLength) {
|
||||
return '${rawResult.runtimeType} $summary';
|
||||
}
|
||||
return '${rawResult.runtimeType} ${summary.substring(0, maximumSummaryLength)}…';
|
||||
}
|
||||
Reference in New Issue
Block a user