diff --git a/README.md b/README.md index a854df8..abe4797 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ A comic reader that support reading local and network comics. - View comments, tags, and other information of comics if the source supports - Login to comment, rate, and other operations if the source supports +## Build from source + +1. Clone the repository +2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install) +3. Install rust, see [rustup.rs](https://rustup.rs/) +4. Build for your platform: e.g. `flutter build apk` + ## Create a new comic source See [venera-configs](https://github.com/venera-app/venera-configs) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0394805..59f3a36 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -75,6 +75,9 @@ android { buildTypes { release { + ndk { + abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + } signingConfig signingConfigs.release applicationVariants.all { variant -> variant.outputs.all { output -> diff --git a/assets/init.js b/assets/init.js index 9f00eb3..932590c 100644 --- a/assets/init.js +++ b/assets/init.js @@ -224,7 +224,25 @@ let Convert = { key: key, isEncode: false }); - } + }, + /** Encode bytes to hex string + * @param bytes {ArrayBuffer} + * @return {string} + */ + hexEncode: (bytes) => { + const hexDigits = '0123456789abcdef'; + const view = new Uint8Array(bytes); + let charCodes = new Uint8Array(view.length * 2); + let j = 0; + + for (let i = 0; i < view.length; i++) { + let byte = view[i]; + charCodes[j++] = hexDigits.charCodeAt((byte >> 4) & 0xF); + charCodes[j++] = hexDigits.charCodeAt(byte & 0xF); + } + + return String.fromCharCode(...charCodes); + }, } /** @@ -999,4 +1017,118 @@ class ComicSource { init() { } static sources = {} -} \ No newline at end of file +} + +/// A reference to dart object. +/// The api can only be used in the comic.onImageLoad.modifyImage function +class Image { + key = 0; + + constructor(key) { + this.key = key; + } + + /** + * Copy the specified range of the image + * @param x + * @param y + * @param width + * @param height + * @returns {Image|null} + */ + copyRange(x, y, width, height) { + let key = sendMessage({ + method: "image", + function: "copyRange", + key: this.key, + x: x, + y: y, + width: width, + height: height + }) + if(key == null) return null; + return new Image(key); + } + + /** + * Copy the image and rotate 90 degrees + * @returns {Image|null} + */ + copyAndRotate90() { + let key = sendMessage({ + method: "image", + function: "copyAndRotate90", + key: this.key + }) + if(key == null) return null; + return new Image(key); + } + + /** + * fill [image] to this image at (x, y) + * @param x + * @param y + * @param image + */ + fillImageAt(x, y, image) { + sendMessage({ + method: "image", + function: "fillImageAt", + key: this.key, + x: x, + y: y, + image: image.key + }) + } + + /** + * fill [image] with range(srcX, srcY, width, height) to this image at (x, y) + * @param x + * @param y + * @param image + * @param srcX + * @param srcY + * @param width + * @param height + */ + fillImageRangeAt(x, y, image, srcX, srcY, width, height) { + sendMessage({ + method: "image", + function: "fillImageRangeAt", + key: this.key, + x: x, + y: y, + image: image.key, + srcX: srcX, + srcY: srcY, + width: width, + height: height + }) + } + + get width() { + return sendMessage({ + method: "image", + function: "getWidth", + key: this.key + }) + } + + get height() { + return sendMessage({ + method: "image", + function: "getHeight", + key: this.key + }) + } + + static empty(width, height) { + let key = sendMessage({ + method: "image", + function: "emptyImage", + width: width, + height: height + }) + return new Image(key); + } +} diff --git a/assets/translation.json b/assets/translation.json index 796bab9..0df06c2 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -19,7 +19,7 @@ "Select": "选择", "Imported @a comics": "已导入 @a 部漫画", "Downloading": "下载中", - "Back": "返回", + "Back": "后退", "Delete": "删除", "Full Screen": "全屏", "Auto Page Turning": "自动翻页", @@ -155,7 +155,21 @@ "Start": "开始", "Export App Data": "导出应用数据", "Import App Data": "导入应用数据", - "Export": "导出" + "Export": "导出", + "Download Threads": "下载线程数", + "Update Time": "更新时间", + "Copy ID": "复制ID", + "Copy URL": "复制URL", + "Create": "创建", + "Folder Name": "文件夹名称", + "Ranking": "排行", + "Download Selected": "下载选中", + "Download All": "下载全部", + "Order": "顺序", + "minAppVersion @version is required": "需要最低App版本 @version", + "Remove": "移除", + "Long press to zoom": "长按缩放", + "Updates Available": "更新可用" }, "zh_TW": { "Home": "首頁", @@ -178,7 +192,7 @@ "Select": "選擇", "Imported @a comics": "已匯入 @a 部漫畫", "Downloading": "下載中", - "Back": "返回", + "Back": "後退", "Delete": "刪除", "Full Screen": "全螢幕", "Auto Page Turning": "自動翻頁", @@ -313,6 +327,20 @@ "Start": "開始", "Export App Data": "匯出應用數據", "Import App Data": "匯入應用數據", - "Export": "匯出" + "Export": "匯出", + "Download Threads": "下載線程數", + "Update Time": "更新時間", + "Copy ID": "複製ID", + "Copy URL": "複製URL", + "Create": "創建", + "Folder Name": "文件夾名稱", + "Ranking": "排行", + "Download Selected": "下載選中", + "Download All": "下載全部", + "Order": "順序", + "minAppVersion @version is required": "需要最低App版本 @version", + "Remove": "移除", + "Long press to zoom": "長按縮放", + "Updates Available": "更新可用" } } \ No newline at end of file diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 2c4c4c3..ab8c073 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -454,7 +454,9 @@ class _ComicDescription extends StatelessWidget { ), ).toAlign(Alignment.topCenter); }), - ), + ) + else + const Spacer(), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ diff --git a/lib/components/message.dart b/lib/components/message.dart index 642dae6..d1fb8aa 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -129,13 +129,14 @@ void showDialogMessage(BuildContext context, String title, String message) { ); } -void showConfirmDialog({ +Future showConfirmDialog({ required BuildContext context, required String title, required String content, required void Function() onConfirm, + String confirmText = "Confirm", }) { - showDialog( + return showDialog( context: context, builder: (context) => ContentDialog( title: title, @@ -146,7 +147,7 @@ void showConfirmDialog({ context.pop(); onConfirm(); }, - child: Text("Confirm".tl), + child: Text(confirmText.tl), ), ], ), diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 056cfc7..e1456b6 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -10,7 +10,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.0.1"; + final version = "1.0.2"; bool get isAndroid => Platform.isAndroid; diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index a81f42e..817aa96 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -110,6 +110,9 @@ class _Settings with ChangeNotifier { 'enablePageAnimation': true, 'language': 'system', // system, zh-CN, zh-TW, en-US 'cacheSize': 2048, // in MB + 'downloadThreads': 5, + 'enableLongPressToZoom': true, + 'checkUpdateOnStart': true, }; operator [](String key) { diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index 06621ad..f4c64df 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -12,6 +12,7 @@ import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/io.dart'; +import 'package:venera/utils/translations.dart'; import '../js_engine.dart'; import '../log.dart'; diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index ed723df..b02c54f 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -106,7 +106,9 @@ class ComicSourceParser { if (minAppVersion != null) { if (compareSemVer(minAppVersion, App.version.split('-').first)) { throw ComicSourceParseException( - "minAppVersion $minAppVersion is required"); + "minAppVersion @version is required" + .tlParams({"version": minAppVersion}), + ); } } for (var source in ComicSource.all()) { @@ -728,7 +730,7 @@ class ComicSourceParser { return retryZone(func); }; - if(_checkExists("favorites.addFolder")) { + if (_checkExists("favorites.addFolder")) { addFolder = (name) async { try { await JsEngine().runCode(""" @@ -741,7 +743,7 @@ class ComicSourceParser { } }; } - if(_checkExists("favorites.deleteFolder")) { + if (_checkExists("favorites.deleteFolder")) { deleteFolder = (key) async { try { await JsEngine().runCode(""" diff --git a/lib/foundation/image_provider/base_image_provider.dart b/lib/foundation/image_provider/base_image_provider.dart index cdc1797..57dd120 100644 --- a/lib/foundation/image_provider/base_image_provider.dart +++ b/lib/foundation/image_provider/base_image_provider.dart @@ -87,17 +87,16 @@ abstract class BaseImageProvider> return await decode(buffer); } catch (e) { await CacheManager().delete(this.key); - Object error = e; if (data.length < 2 * 1024) { // data is too short, it's likely that the data is text, not image try { var text = const Utf8Codec(allowMalformed: false).decoder.convert(data); - error = Exception("Expected image data, but got text: $text"); + throw Exception("Expected image data, but got text: $text"); } catch (e) { // ignore } } - throw error; + rethrow; } } catch (e) { scheduleMicrotask(() { diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 2eefa60..8ae647f 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; - import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; @@ -238,7 +237,7 @@ mixin class _JSEngineApi { Log.warning( "JS Engine", "Too many documents, deleting the oldest: $shouldDelete\n" - "Current documents: ${_documents.keys}", + "Current documents: ${_documents.keys}", ); _documents.remove(shouldDelete); } @@ -350,9 +349,6 @@ mixin class _JSEngineApi { case "utf8": return isEncode ? utf8.encode(value) : utf8.decode(value); case "base64": - if (value is String) { - value = utf8.encode(value); - } return isEncode ? base64Encode(value) : base64Decode(value); case "md5": return Uint8List.fromList(md5.convert(value).bytes); @@ -383,8 +379,21 @@ mixin class _JSEngineApi { if (!isEncode) { var key = data["key"]; var cipher = ECBBlockCipher(AESEngine()); - cipher.init(false, KeyParameter(key)); - return cipher.process(value); + cipher.init( + false, + KeyParameter(key), + ); + var offset = 0; + var result = Uint8List(value.length); + while (offset < value.length) { + offset += cipher.processBlock( + value, + offset, + result, + offset, + ); + } + return result; } return null; case "aes-cbc": @@ -393,7 +402,17 @@ mixin class _JSEngineApi { var iv = data["iv"]; var cipher = CBCBlockCipher(AESEngine()); cipher.init(false, ParametersWithIV(KeyParameter(key), iv)); - return cipher.process(value); + var offset = 0; + var result = Uint8List(value.length); + while (offset < value.length) { + offset += cipher.processBlock( + value, + offset, + result, + offset, + ); + } + return result; } return null; case "aes-cfb": @@ -402,7 +421,17 @@ mixin class _JSEngineApi { var blockSize = data["blockSize"]; var cipher = CFBBlockCipher(AESEngine(), blockSize); cipher.init(false, KeyParameter(key)); - return cipher.process(value); + var offset = 0; + var result = Uint8List(value.length); + while (offset < value.length) { + offset += cipher.processBlock( + value, + offset, + result, + offset, + ); + } + return result; } return null; case "aes-ofb": @@ -411,7 +440,17 @@ mixin class _JSEngineApi { var blockSize = data["blockSize"]; var cipher = OFBBlockCipher(AESEngine(), blockSize); cipher.init(false, KeyParameter(key)); - return cipher.process(value); + var offset = 0; + var result = Uint8List(value.length); + while (offset < value.length) { + offset += cipher.processBlock( + value, + offset, + result, + offset, + ); + } + return result; } return null; case "rsa": @@ -426,8 +465,8 @@ mixin class _JSEngineApi { default: return value; } - } catch (e) { - Log.error("JS Engine", "Failed to convert $type: $e"); + } catch (e, s) { + Log.error("JS Engine", "Failed to convert $type: $e", s); return null; } } diff --git a/lib/main.dart b/lib/main.dart index 9a7be51..7351392 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,8 +3,12 @@ import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/log.dart'; +import 'package:venera/network/app_dio.dart'; +import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/main_page.dart'; +import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/app_links.dart'; import 'package:window_manager/window_manager.dart'; import 'components/components.dart'; @@ -18,6 +22,7 @@ void main(List args) { return; } runZonedGuarded(() async { + await Rhttp.init(); WidgetsFlutterBinding.ensureInitialized(); await init(); if (App.isAndroid) { @@ -63,6 +68,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { @override void initState() { + checkUpdates(); App.registerForceRebuild(forceRebuild); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.initState(); @@ -163,6 +169,22 @@ class _MyAppState extends State { }, ); } + + void checkUpdates() async { + if(!appdata.settings['checkUpdateOnStart']) { + return; + } + var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; + var now = DateTime.now().millisecondsSinceEpoch; + if(now - lastCheck < 24 * 60 * 60 * 1000) { + return; + } + appdata.implicitData['lastCheckUpdate'] = now; + appdata.writeImplicitData(); + await Future.delayed(const Duration(milliseconds: 300)); + await checkUpdateUi(false); + await ComicSourcePage.checkComicSourceUpdate(true); + } } class _SystemUiProvider extends StatelessWidget { diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 0a060a1..dbbc7bc 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; import 'package:flutter/services.dart'; +import 'package:rhttp/rhttp.dart' as rhttp; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/network/cache.dart'; @@ -109,7 +109,7 @@ class AppDio with DioMixin { AppDio([BaseOptions? options]) { this.options = options ?? BaseOptions(); interceptors.add(MyLogInterceptor()); - httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient); + httpClientAdapter = RHttpAdapter(const rhttp.ClientSettings()); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); interceptors.add(NetworkCacheManager()); interceptors.add(CloudflareInterceptor()); @@ -136,8 +136,9 @@ class AppDio with DioMixin { static String? proxy; static Future getProxy() async { - if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") + if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") { return null; + } if (appdata.settings['proxy'] != "system") return appdata.settings['proxy']; String res; @@ -187,10 +188,13 @@ class AppDio with DioMixin { }) async { proxy = await getProxy(); if (_proxy != proxy) { + Log.info("Network", "Proxy changed to $proxy"); _proxy = proxy; - (httpClientAdapter as IOHttpClientAdapter).close(); - httpClientAdapter = - IOHttpClientAdapter(createHttpClient: createHttpClient); + httpClientAdapter = RHttpAdapter(rhttp.ClientSettings( + proxySettings: proxy == null + ? const rhttp.ProxySettings.noProxy() + : rhttp.ProxySettings.proxy(proxy!), + )); } Log.info( "Network", @@ -209,3 +213,81 @@ class AppDio with DioMixin { ); } } + +class RHttpAdapter implements HttpClientAdapter { + rhttp.ClientSettings settings; + + RHttpAdapter(this.settings) { + settings.copyWith( + redirectSettings: const rhttp.RedirectSettings.limited(5), + timeoutSettings: const rhttp.TimeoutSettings( + connectTimeout: Duration(seconds: 15), + keepAliveTimeout: Duration(seconds: 60), + keepAlivePing: Duration(seconds: 30), + ), + httpVersionPref: rhttp.HttpVersionPref.http1_1, + ); + } + + @override + void close({bool force = false}) {} + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + var res = await rhttp.Rhttp.request( + method: switch (options.method) { + 'GET' => rhttp.HttpMethod.get, + 'POST' => rhttp.HttpMethod.post, + 'PUT' => rhttp.HttpMethod.put, + 'PATCH' => rhttp.HttpMethod.patch, + 'DELETE' => rhttp.HttpMethod.delete, + 'HEAD' => rhttp.HttpMethod.head, + 'OPTIONS' => rhttp.HttpMethod.options, + 'TRACE' => rhttp.HttpMethod.trace, + 'CONNECT' => rhttp.HttpMethod.connect, + _ => throw ArgumentError('Unsupported method: ${options.method}'), + }, + url: options.uri.toString(), + settings: settings, + expectBody: rhttp.HttpExpectBody.stream, + body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream), + headers: rhttp.HttpHeaders.rawMap( + Map.fromEntries( + options.headers.entries.map( + (e) => MapEntry(e.key, e.value.toString().trim()), + ), + ), + ), + ); + if (res is! rhttp.HttpStreamResponse) { + throw Exception("Invalid response type: ${res.runtimeType}"); + } + var headers = >{}; + for (var entry in res.headers) { + var key = entry.$1.toLowerCase(); + headers[key] ??= []; + headers[key]!.add(entry.$2); + } + var data = res.body; + if(headers['content-encoding']?.contains('gzip') ?? false) { + // rhttp does not support gzip decoding + var buffer = []; + await for (var chunk in data) { + buffer.addAll(chunk); + } + data = Stream.value(Uint8List.fromList(gzip.decode(buffer))); + buffer.clear(); + } + return ResponseBody( + data, + res.statusCode, + statusMessage: null, + isRedirect: false, + headers: headers, + ); + } +} diff --git a/lib/network/download.dart b/lib/network/download.dart index 052a8c0..f0c3748 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart' show ChangeNotifier; +import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/local.dart'; @@ -155,7 +156,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { var tasks = {}; - int get _maxConcurrentTasks => 5; + int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt(); void _scheduleTasks() { var images = _images![_images!.keys.elementAt(_chapter)]!; diff --git a/lib/network/images.dart b/lib/network/images.dart index ea887bd..29c9005 100644 --- a/lib/network/images.dart +++ b/lib/network/images.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/consts.dart'; +import 'package:venera/utils/image.dart'; import 'app_dio.dart'; @@ -27,8 +28,8 @@ class ImageDownloader { configs = comicSource?.getThumbnailLoadingConfig?.call(url) ?? {}; } configs['headers'] ??= {}; - if(configs['headers']['user-agent'] == null - && configs['headers']['User-Agent'] == null) { + if (configs['headers']['user-agent'] == null && + configs['headers']['User-Agent'] == null) { configs['headers']['user-agent'] = webUA; } @@ -120,11 +121,22 @@ class ImageDownloader { buffer = configs['onResponse'](buffer); } - await CacheManager().writeCache(cacheKey, buffer); + var data = Uint8List.fromList(buffer); + buffer.clear(); + + if (configs['modifyImage'] != null) { + var newData = await modifyImageWithScript( + data, + configs['modifyImage'], + ); + data = newData; + } + + await CacheManager().writeCache(cacheKey, data); yield ImageDownloadProgress( - currentBytes: buffer.length, - totalBytes: buffer.length, - imageBytes: Uint8List.fromList(buffer), + currentBytes: data.length, + totalBytes: data.length, + imageBytes: data, ); } } diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 7db2886..3488b66 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -1576,7 +1576,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { const SizedBox(width: 16), Expanded( child: FilledButton( - onPressed: () { + onPressed: selected.isEmpty ? null : () { widget.finishSelect(selected); context.pop(); }, @@ -1587,7 +1587,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { ], ), ), - SizedBox(height: MediaQuery.of(context).padding.bottom + 4), + SizedBox(height: MediaQuery.of(context).padding.bottom), ], ), ); diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 5710473..95c5f67 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -14,11 +14,11 @@ import 'package:venera/utils/translations.dart'; class ComicSourcePage extends StatefulWidget { const ComicSourcePage({super.key}); - static void checkComicSourceUpdate([bool showLoading = false]) async { + static Future checkComicSourceUpdate([bool implicit = false]) async { if (ComicSource.all().isEmpty) { return; } - var controller = showLoading ? showLoadingDialog(App.rootContext) : null; + var controller = implicit ? null : showLoadingDialog(App.rootContext); var dio = AppDio(); var res = await dio.get( "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json"); @@ -40,7 +40,9 @@ class ComicSourcePage extends StatefulWidget { } controller?.close(); if (shouldUpdate.isEmpty) { - App.rootContext.showMessage(message: "No Update Available".tl); + if(!implicit) { + App.rootContext.showMessage(message: "No Update Available".tl); + } return; } var msg = ""; @@ -48,10 +50,11 @@ class ComicSourcePage extends StatefulWidget { msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n"; } msg = msg.trim(); - showConfirmDialog( + await showConfirmDialog( context: App.rootContext, title: "Updates Available".tl, content: msg, + confirmText: "Update", onConfirm: () { for (var key in shouldUpdate) { var source = ComicSource.find(key); @@ -104,7 +107,7 @@ class _BodyState extends State<_Body> { child: ListTile( leading: const Icon(Icons.update_outlined), title: Text("Check updates".tl), - onTap: () => ComicSourcePage.checkComicSourceUpdate(true), + onTap: () => ComicSourcePage.checkComicSourceUpdate(false), trailing: const Icon(Icons.arrow_right), ), ); @@ -161,71 +164,76 @@ class _BodyState extends State<_Body> { for (var item in source.settings!.entries) { var key = item.key; String type = item.value['type']; - if (type == "select") { - var current = source.data['settings'][key]; - if (current == null) { - var d = item.value['default']; - for (var option in item.value['options']) { - if (option['value'] == d) { - current = option['text'] ?? option['value']; - break; + try { + if (type == "select") { + var current = source.data['settings'][key]; + if (current == null) { + var d = item.value['default']; + for (var option in item.value['options']) { + if (option['value'] == d) { + current = option['text'] ?? option['value']; + break; + } } } + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: Select( + current: (current as String).ts(source.key), + values: (item.value['options'] as List) + .map( + (e) => ((e['text'] ?? e['value']) as String).ts(source.key)) + .toList(), + onTap: (i) { + source.data['settings'][key] = item.value['options'][i]['value']; + source.saveData(); + setState(() {}); + }, + ), + ); + } else if (type == "switch") { + var current = source.data['settings'][key] ?? item.value['default']; + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + trailing: Switch( + value: current, + onChanged: (v) { + source.data['settings'][key] = v; + source.saveData(); + setState(() {}); + }, + ), + ); + } else if (type == "input") { + var current = + source.data['settings'][key] ?? item.value['default'] ?? ''; + yield ListTile( + title: Text((item.value['title'] as String).ts(source.key)), + subtitle: Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + showInputDialog( + context: context, + title: (item.value['title'] as String).ts(source.key), + initialValue: current, + inputValidator: item.value['validator'] == null + ? null + : RegExp(item.value['validator']), + onConfirm: (value) { + source.data['settings'][key] = value; + source.saveData(); + setState(() {}); + return null; + }, + ); + }, + ), + ); } - yield ListTile( - title: Text((item.value['title'] as String).ts(source.key)), - trailing: Select( - current: (current as String).ts(source.key), - values: (item.value['options'] as List) - .map( - (e) => ((e['text'] ?? e['value']) as String).ts(source.key)) - .toList(), - onTap: (i) { - source.data['settings'][key] = item.value['options'][i]['value']; - source.saveData(); - setState(() {}); - }, - ), - ); - } else if (type == "switch") { - var current = source.data['settings'][key] ?? item.value['default']; - yield ListTile( - title: Text((item.value['title'] as String).ts(source.key)), - trailing: Switch( - value: current, - onChanged: (v) { - source.data['settings'][key] = v; - source.saveData(); - setState(() {}); - }, - ), - ); - } else if (type == "input") { - var current = - source.data['settings'][key] ?? item.value['default'] ?? ''; - yield ListTile( - title: Text((item.value['title'] as String).ts(source.key)), - subtitle: Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - showInputDialog( - context: context, - title: (item.value['title'] as String).ts(source.key), - initialValue: current, - inputValidator: item.value['validator'] == null - ? null - : RegExp(item.value['validator']), - onConfirm: (value) { - source.data['settings'][key] = value; - source.saveData(); - setState(() {}); - return null; - }, - ); - }, - ), - ); + } + catch(e, s) { + Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s"); } } } @@ -446,10 +454,11 @@ class _ComicSourceListState extends State<_ComicSourceList> { itemBuilder: (context, index) { var key = json![index]["key"]; var action = currentKey.contains(key) - ? const Icon(Icons.check) + ? const Icon(Icons.check, size: 20).paddingRight(8) : Tooltip( message: "Add", - child: IconButton( + child: Button.icon( + color: context.colorScheme.primary, icon: const Icon(Icons.add), onPressed: () async { await widget.onAdd( diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index f1c427f..578b893 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -223,6 +223,9 @@ class _GalleryModeState extends State<_GalleryMode> @override void handleLongPressDown(Offset location) { + if(!appdata.settings['enableLongPressToZoom']) { + return; + } var photoViewController = photoViewControllers[reader.page]!; double target = photoViewController.getInitialScale!.call()! * 1.75; var size = MediaQuery.of(context).size; @@ -234,6 +237,9 @@ class _GalleryModeState extends State<_GalleryMode> @override void handleLongPressUp(Offset location) { + if(!appdata.settings['enableLongPressToZoom']) { + return; + } var photoViewController = photoViewControllers[reader.page]!; double target = photoViewController.getInitialScale!.call()!; photoViewController.animateScale?.call(target); @@ -509,6 +515,9 @@ class _ContinuousModeState extends State<_ContinuousMode> @override void handleLongPressDown(Offset location) { + if(!appdata.settings['enableLongPressToZoom']) { + return; + } double target = photoViewController.getInitialScale!.call()! * 1.75; var size = MediaQuery.of(context).size; photoViewController.animateScale?.call( @@ -519,6 +528,9 @@ class _ContinuousModeState extends State<_ContinuousMode> @override void handleLongPressUp(Offset location) { + if(!appdata.settings['enableLongPressToZoom']) { + return; + } double target = photoViewController.getInitialScale!.call()!; photoViewController.animateScale?.call(target); } diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 8353628..598e173 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -393,7 +393,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { Widget buildPageInfoText() { var epName = context.reader.widget.chapters?.values - .elementAt(context.reader.chapter - 1) ?? + .elementAtOrNull(context.reader.chapter - 1) ?? "E${context.reader.chapter}"; if (epName.length > 8) { epName = "${epName.substring(0, 8)}..."; diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index f9a46da..f68ef69 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -53,30 +53,7 @@ class _AboutSettingsState extends State { setState(() { isCheckingUpdate = true; }); - checkUpdate().then((value) { - if (value) { - showDialog( - context: App.rootContext, - builder: (context) { - return ContentDialog( - title: "New version available".tl, - content: Text( - "A new version is available. Do you want to update now?" - .tl), - actions: [ - Button.text( - onPressed: () { - Navigator.pop(context); - launchUrlString( - "https://github.com/venera-app/venera/releases"); - }, - child: Text("Update".tl), - ), - ]); - }); - } else { - context.showMessage(message: "No new version available".tl); - } + checkUpdateUi().then((value) { setState(() { isCheckingUpdate = false; }); @@ -108,6 +85,33 @@ Future checkUpdate() async { return false; } +Future checkUpdateUi([bool showMessageIfNoUpdate = true]) async { + var value = await checkUpdate(); + if (value) { + showDialog( + context: App.rootContext, + builder: (context) { + return ContentDialog( + title: "New version available".tl, + content: Text( + "A new version is available. Do you want to update now?".tl), + actions: [ + Button.text( + onPressed: () { + Navigator.pop(context); + launchUrlString( + "https://github.com/venera-app/venera/releases"); + }, + child: Text("Update".tl), + ), + ], + ); + }); + } else if (showMessageIfNoUpdate) { + App.rootContext.showMessage(message: "No new version available".tl); + } +} + /// return true if version1 > version2 bool _compareVersion(String version1, String version2) { var v1 = version1.split("."); diff --git a/lib/pages/settings/network.dart b/lib/pages/settings/network.dart index 0cc2545..21042ea 100644 --- a/lib/pages/settings/network.dart +++ b/lib/pages/settings/network.dart @@ -17,6 +17,13 @@ class _NetworkSettingsState extends State { title: "Proxy".tl, builder: () => const _ProxySettingView(), ).toSliver(), + _SliderSetting( + title: "Download Threads".tl, + settingsIndex: 'downloadThreads', + interval: 1, + min: 1, + max: 16, + ).toSliver(), ], ); } @@ -42,50 +49,50 @@ class _ProxySettingViewState extends State<_ProxySettingView> { // USERNAME:PASSWORD@HOST:PORT String toProxyStr() { - if(type == 'direct') { + if (type == 'direct') { return 'direct'; - } else if(type == 'system') { + } else if (type == 'system') { return 'system'; } var res = ''; - if(username.isNotEmpty) { + if (username.isNotEmpty) { res += username; - if(password.isNotEmpty) { + if (password.isNotEmpty) { res += ':$password'; } res += '@'; } res += host; - if(port.isNotEmpty) { + if (port.isNotEmpty) { res += ':$port'; } return res; } void parseProxyString(String proxy) { - if(proxy == 'direct') { + if (proxy == 'direct') { type = 'direct'; return; - } else if(proxy == 'system') { + } else if (proxy == 'system') { type = 'system'; return; } type = 'manual'; var parts = proxy.split('@'); - if(parts.length == 2) { + if (parts.length == 2) { var auth = parts[0].split(':'); - if(auth.length == 2) { + if (auth.length == 2) { username = auth[0]; password = auth[1]; } parts = parts[1].split(':'); - if(parts.length == 2) { + if (parts.length == 2) { host = parts[0]; port = parts[1]; } } else { parts = proxy.split(':'); - if(parts.length == 2) { + if (parts.length == 2) { host = parts[0]; port = parts[1]; } @@ -140,7 +147,7 @@ class _ProxySettingViewState extends State<_ProxySettingView> { }); }, ), - if(type == 'manual') buildManualProxy(), + if (type == 'manual') buildManualProxy(), ], ), ), @@ -164,7 +171,7 @@ class _ProxySettingViewState extends State<_ProxySettingView> { host = v; }, validator: (v) { - if(v?.isEmpty ?? false) { + if (v?.isEmpty ?? false) { return "Host cannot be empty".tl; } return null; @@ -181,10 +188,10 @@ class _ProxySettingViewState extends State<_ProxySettingView> { port = v; }, validator: (v) { - if(v?.isEmpty ?? true) { + if (v?.isEmpty ?? true) { return null; } - if(int.tryParse(v!) == null) { + if (int.tryParse(v!) == null) { return "Port must be a number".tl; } return null; @@ -201,7 +208,7 @@ class _ProxySettingViewState extends State<_ProxySettingView> { username = v; }, validator: (v) { - if((v?.isEmpty ?? false) && password.isNotEmpty) { + if ((v?.isEmpty ?? false) && password.isNotEmpty) { return "Username cannot be empty".tl; } return null; @@ -221,7 +228,7 @@ class _ProxySettingViewState extends State<_ProxySettingView> { const SizedBox(height: 16), FilledButton( onPressed: () { - if(formKey.currentState?.validate() ?? false) { + if (formKey.currentState?.validate() ?? false) { appdata.settings['proxy'] = toProxyStr(); appdata.saveData(); App.rootContext.pop(); diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 4704d24..e8cb88d 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -54,6 +54,13 @@ class _ReaderSettingsState extends State { widget.onChanged?.call("autoPageTurningInterval"); }, ).toSliver(), + _SwitchSetting( + title: 'Long press to zoom'.tl, + settingKey: 'enableLongPressToZoom', + onChanged: () { + widget.onChanged?.call('enableLongPressToZoom'); + }, + ).toSliver(), ], ); } diff --git a/lib/utils/image.dart b/lib/utils/image.dart new file mode 100644 index 0000000..b7488b8 --- /dev/null +++ b/lib/utils/image.dart @@ -0,0 +1,316 @@ +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/services.dart'; +import 'package:flutter_qjs/flutter_qjs.dart'; +import 'package:lodepng_flutter/lodepng_flutter.dart' as lodepng; + +class Image { + final Uint32List _data; + + final int width; + + final int height; + + Image(this._data, this.width, this.height) { + if (_data.length != width * height) { + throw ArgumentError( + 'Invalid argument: data length must be equal to width * height.'); + } + } + + Image.empty(this.width, this.height) : _data = Uint32List(width * height); + + static Future decodeImage(Uint8List data) async { + var codec = await ui.instantiateImageCodec(data); + var frame = await codec.getNextFrame(); + codec.dispose(); + var info = await frame.image.toByteData(); + if (info == null) { + throw Exception('Failed to decode image'); + } + var image = Image( + info.buffer.asUint32List(), + frame.image.width, + frame.image.height, + ); + frame.image.dispose(); + return image; + } + + Image copyRange(int x, int y, int width, int height) { + if (width + x > this.width) { + throw ArgumentError(''' + Invalid argument: x + width must be less than or equal to the image width. + x: $x, width: $width, image width: ${this.width} + ''' + .trim()); + } + if (height + y > this.height) { + throw ArgumentError(''' + Invalid argument: y + height must be less than or equal to the image height. + y: $y, height: $height, image height: ${this.height} + ''' + .trim()); + } + var data = Uint32List(width * height); + for (var j = 0; j < height; j++) { + for (var i = 0; i < width; i++) { + data[j * width + i] = _data[(j + y) * this.width + i + x]; + } + } + return Image(data, width, height); + } + + void fillImageAt(int x, int y, Image image) { + if (x + image.width > width) { + throw ArgumentError(''' + Invalid argument: x + image width must be less than or equal to the image width. + x: $x, image width: ${image.width}, image width: $width + ''' + .trim()); + } + if (y + image.height > height) { + throw ArgumentError(''' + Invalid argument: y + image height must be less than or equal to the image height. + y: $y, image height: ${image.height}, image height: $height + ''' + .trim()); + } + for (var j = 0; j < image.height && (j + y) < height; j++) { + for (var i = 0; i < image.width && (i + x) < width; i++) { + _data[(j + y) * width + i + x] = image._data[j * image.width + i]; + } + } + } + + void fillImageRangeAt( + int x, int y, Image image, int srcX, int srcY, int width, int height) { + if (x + width > this.width) { + throw ArgumentError(''' + Invalid argument: x + width must be less than or equal to the image width. + x: $x, width: $width, image width: ${this.width} + ''' + .trim()); + } + if (y + height > this.height) { + throw ArgumentError(''' + Invalid argument: y + height must be less than or equal to the image height. + y: $y, height: $height, image height: ${this.height} + ''' + .trim()); + } + if (srcX + width > image.width) { + throw ArgumentError(''' + Invalid argument: srcX + width must be less than or equal to the image width. + srcX: $srcX, width: $width, image width: ${image.width} + ''' + .trim()); + } + if (srcY + height > image.height) { + throw ArgumentError(''' + Invalid argument: srcY + height must be less than or equal to the image height. + srcY: $srcY, height: $height, image height: ${image.height} + ''' + .trim()); + } + for (var j = 0; j < height; j++) { + for (var i = 0; i < width; i++) { + _data[(j + y) * this.width + i + x] = + image._data[(j + srcY) * image.width + i + srcX]; + } + } + } + + Image copyAndRotate90() { + var data = Uint32List(width * height); + for (var j = 0; j < height; j++) { + for (var i = 0; i < width; i++) { + data[i * height + height - j - 1] = _data[j * width + i]; + } + } + return Image(data, height, width); + } + + Color getPixel(int x, int y) { + if (x < 0 || x >= width) { + throw ArgumentError( + 'Invalid argument: x must be in the range of [0, $width).'); + } + if (y < 0 || y >= height) { + throw ArgumentError( + 'Invalid argument: y must be in the range of [0, $height).'); + } + return Color.fromValue(_data[y * width + x]); + } + + void setPixel(int x, int y, Color color) { + if (x < 0 || x >= width) { + throw ArgumentError( + 'Invalid argument: x must be in the range of [0, $width).'); + } + if (y < 0 || y >= height) { + throw ArgumentError( + 'Invalid argument: y must be in the range of [0, $height).'); + } + _data[y * width + x] = color.value; + } + + Uint8List encodePng() { + var data = lodepng.encodePngToPointer(lodepng.Image( + _data.buffer.asUint8List(), + width, + height, + )); + return Pointer.fromAddress(data.address).asTypedList(data.length, + finalizer: lodepng.ByteBuffer.finalizer); + } +} + +class Color { + final int value; + + Color(int r, int g, int b, [int a = 255]) + : value = (a << 24) | (r << 16) | (g << 8) | b; + + Color.fromValue(this.value); + + int get r => (value >> 16) & 0xFF; + + int get g => (value >> 8) & 0xFF; + + int get b => value & 0xFF; + + int get a => (value >> 24) & 0xFF; +} + +class JsEngine { + static final JsEngine _instance = JsEngine._(); + + factory JsEngine() => _instance; + + JsEngine._() { + _engine = FlutterQjs(); + _engine!.dispatch(); + var setGlobalFunc = + _engine!.evaluate("(key, value) => { this[key] = value; }"); + (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); + setGlobalFunc.free(); + } + + FlutterQjs? _engine; + + dynamic runCode(String js, [String? name]) { + return _engine!.evaluate(js, name: name); + } + + var images = {}; + + int _key = 0; + + int setImage(Image image) { + var key = _key++; + images[key] = image; + return key; + } + + Object? _messageReceiver(dynamic message) { + if (message is! Map) return null; + var method = message['method']; + if (method == 'image') { + switch (message['function']) { + case 'copyRange': + var key = message['key']; + var image = images[key]; + if (image == null) return null; + var x = message['x']; + var y = message['y']; + var width = message['width']; + var height = message['height']; + var newImage = image.copyRange(x, y, width, height); + return setImage(newImage); + case 'copyAndRotate90': + var key = message['key']; + var image = images[key]; + if (image == null) return null; + var newImage = image.copyAndRotate90(); + return setImage(newImage); + case 'fillImageAt': + var key = message['key']; + var image = images[key]; + if (image == null) return null; + var x = message['x']; + var y = message['y']; + var key2 = message['image']; + var image2 = images[key2]; + if (image2 == null) return null; + image.fillImageAt(x, y, image2); + return null; + case 'fillImageRangeAt': + var key = message['key']; + var image = images[key]; + if (image == null) return null; + var x = message['x']; + var y = message['y']; + var key2 = message['image']; + var image2 = images[key2]; + if (image2 == null) return null; + var srcX = message['srcX']; + var srcY = message['srcY']; + var width = message['width']; + var height = message['height']; + image.fillImageRangeAt(x, y, image2, srcX, srcY, width, height); + return null; + case 'getWidth': + var key = message['key']; + var image = images[key]; + if (image == null) return null; + return image.width; + case 'getHeight': + var key = message['key']; + var image = images[key]; + if (image == null) return null; + return image.height; + case 'emptyImage': + var width = message['width']; + var height = message['height']; + var newImage = Image.empty(width, height); + return setImage(newImage); + } + } + return null; + } +} + +var _tasksCount = 0; + +Future modifyImageWithScript(Uint8List data, String script) async { + while (_tasksCount > 3) { + await Future.delayed(const Duration(milliseconds: 200)); + } + _tasksCount++; + try { + var image = await Image.decodeImage(data); + var initJs = await rootBundle.loadString('assets/init.js'); + return await Isolate.run(() { + var jsEngine = JsEngine(); + jsEngine.runCode(initJs, ''); + jsEngine.runCode(script); + var key = jsEngine.setImage(image); + var res = jsEngine.runCode(''' + let func = () => { + let image = new Image($key); + let result = modifyImage(image); + return result.key; + } + func(); + '''); + var newImage = jsEngine.images[res]; + var data = newImage!.encodePng(); + return Uint8List.fromList(data); + }); + } finally { + _tasksCount--; + } +} diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c0fb5f6..819bf88 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -14,6 +14,8 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + lodepng_flutter + rhttp zip_flutter ) diff --git a/pubspec.lock b/pubspec.lock index 623cf1d..fdf85c8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172 + url: "https://pub.dev" + source: hosted + version: "2.1.0" characters: dependency: transitive description: @@ -341,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.1" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "5fe868d3cb8cbc4d83091748552e03f00ccfa41b8e44691bc382611f831d5f8b" + url: "https://pub.dev" + source: hosted + version: "2.5.1" flutter_test: dependency: "direct dev" description: flutter @@ -368,6 +384,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" gtk: dependency: transitive description: @@ -424,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -456,6 +488,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + lodepng_flutter: + dependency: "direct main" + description: + path: "." + ref: d1c96cd6503103b3270dfe2f320d4a1c93780f53 + resolved-ref: d1c96cd6503103b3270dfe2f320d4a1c93780f53 + url: "https://github.com/venera-app/lodepng_flutter" + source: git + version: "0.0.1" matcher: dependency: transitive description: @@ -585,6 +626,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" + rhttp: + dependency: "direct main" + description: + name: rhttp + sha256: "92fb57dea6338370efe1e4e2101e8b521f91f15bc60ef6908469b4392dd9803a" + url: "https://pub.dev" + source: hosted + version: "0.9.1" screen_retriever: dependency: transitive description: @@ -849,5 +898,5 @@ packages: source: git version: "0.0.1" sdks: - dart: ">=3.5.0 <4.0.0" + dart: ">=3.5.4 <4.0.0" flutter: ">=3.24.4" diff --git a/pubspec.yaml b/pubspec.yaml index fb39da7..c71cbf9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.0.1+101 +version: 1.0.2+102 environment: sdk: '>=3.5.0 <4.0.0' @@ -54,6 +54,11 @@ dependencies: zip_flutter: git: url: https://github.com/wgh136/zip_flutter + lodepng_flutter: + git: + url: https://github.com/venera-app/lodepng_flutter + ref: d1c96cd6503103b3270dfe2f320d4a1c93780f53 + rhttp: 0.9.1 dev_dependencies: flutter_test: diff --git a/windows/build.iss b/windows/build.iss index 949b5d0..905d0cf 100644 --- a/windows/build.iss +++ b/windows/build.iss @@ -54,6 +54,7 @@ Source: "{#RootPath}\build\windows\x64\runner\Release\url_launcher_windows_plugi Source: "{#RootPath}\build\windows\x64\runner\Release\screen_retriever_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 693c576..d92ca11 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -16,6 +16,8 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + lodepng_flutter + rhttp zip_flutter )