add loginWithWebview, mixed explore page, app links, html node api;

improve ui
This commit is contained in:
nyne
2024-10-17 12:27:20 +08:00
parent d01d0b5ddb
commit 6c8a7d62a6
28 changed files with 686 additions and 199 deletions

View File

@@ -25,6 +25,24 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="nhentai.net" android:pathPrefix="/g" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="e-hentai.org" android:pathPrefix="/g" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -1,5 +1,7 @@
/*
Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app.
*/
/// encode, decode, hash, decrypt
@@ -305,7 +307,7 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @returns {Promise<ArrayBuffer>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: ArrayBuffer}>} The response from the request.
*/
async fetchBytes(method, url, headers, data) {
let result = await sendMessage({
@@ -330,7 +332,7 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @returns {Promise<Object>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async sendRequest(method, url, headers, data) {
let result = await sendMessage({
@@ -352,7 +354,7 @@ let Network = {
* Sends an HTTP GET request.
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @returns {Promise<Object>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async get(url, headers) {
return this.sendRequest('GET', url, headers);
@@ -363,7 +365,7 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @returns {Promise<Object>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async post(url, headers, data) {
return this.sendRequest('POST', url, headers, data);
@@ -374,7 +376,7 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @returns {Promise<Object>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async put(url, headers, data) {
return this.sendRequest('PUT', url, headers, data);
@@ -385,7 +387,7 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @returns {Promise<Object>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async patch(url, headers, data) {
return this.sendRequest('PATCH', url, headers, data);
@@ -395,7 +397,7 @@ let Network = {
* Sends an HTTP DELETE request.
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @returns {Promise<Object>} The response from the request.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async delete(url, headers) {
return this.sendRequest('DELETE', url, headers);
@@ -577,6 +579,91 @@ class HtmlElement {
})
return ks.map(k => new HtmlElement(k));
}
/**
* Get the nodes of the current element.
* @returns {HtmlNode[]} An array of nodes.
*/
get nodes() {
let ks = sendMessage({
method: "html",
function: "getNodes",
key: this.key
})
return ks.map(k => new HtmlNode(k));
}
/**
* Get inner HTML of the element.
* @returns {string} The inner HTML.
*/
get innerHTML() {
return sendMessage({
method: "html",
function: "getInnerHTML",
key: this.key
})
}
/**
* Get parent element of the element. If the element has no parent, return null.
* @returns {HtmlElement|null}
*/
get parent() {
let k = sendMessage({
method: "html",
function: "getParent",
key: this.key
})
if(!k) return null;
return new HtmlElement(k);
}
}
class HtmlNode {
key = 0;
constructor(k) {
this.key = k;
}
/**
* Get the text content of the node.
* @returns {string} The text content.
*/
get text() {
return sendMessage({
method: "html",
function: "node_text",
key: this.key
})
}
/**
* Get the type of the node.
* @returns {string} The type of the node. ("text", "element", "comment", "document", "unknown")
*/
get type() {
return sendMessage({
method: "html",
function: "node_type",
key: this.key
})
}
/**
* Convert the node to an HtmlElement. If the node is not an element, return null.
* @returns {HtmlElement|null}
*/
toElement() {
let k = sendMessage({
method: "html",
function: "node_toElement",
key: this.key
})
if(!k) return null;
return new HtmlElement(k);
}
}
function log(level, title, content) {
@@ -608,10 +695,11 @@ let console = {
* @param cover {string}
* @param tags {string[]}
* @param description {string}
* @param maxPage {number | null}
* @param maxPage {number?}
* @param language {string?}
* @constructor
*/
function Comic({id, title, subtitle, cover, tags, description, maxPage}) {
function Comic({id, title, subtitle, cover, tags, description, maxPage, language}) {
this.id = id;
this.title = title;
this.subtitle = subtitle;
@@ -619,26 +707,27 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage}) {
this.tags = tags;
this.description = description;
this.maxPage = maxPage;
this.language = language;
}
/**
* Create a comic details object
* @param title {string}
* @param cover {string}
* @param description {string | null}
* @param tags {Map<string, string[]> | {} | null}
* @param chapters {Map<string, string> | {} | null} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null} - favorite status. If the comic source supports multiple folders, this field should be null
* @param subId {string | null} - a param which is passed to comments api
* @param thumbnails {string[] | null} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[] | null} - related comics
* @param commentCount {number | null}
* @param likesCount {number | null}
* @param isLiked {boolean | null}
* @param uploader {string | null}
* @param updateTime {string | null}
* @param uploadTime {string | null}
* @param url {string | null}
* @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined}} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined}} - favorite status. If the comic source supports multiple folders, this field should be null
* @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]? - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics
* @param commentCount {number?}
* @param likesCount {number?}
* @param isLiked {boolean?}
* @param uploader {string?}
* @param updateTime {string?}
* @param uploadTime {string?}
* @param url {string?}
* @constructor
*/
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url}) {

View File

@@ -16,13 +16,14 @@ class SliverGridViewWithFixedItemHeight extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: ((context, constraints) => SliverGrid(
builder: (context, constraints) => SliverGrid(
delegate: delegate,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
childAspectRatio:
calcChildAspectRatio(constraints.crossAxisExtent)),
)));
childAspectRatio: calcChildAspectRatio(constraints.crossAxisExtent),
),
),
);
}
double calcChildAspectRatio(double width) {
@@ -35,7 +36,7 @@ class SliverGridViewWithFixedItemHeight extends StatelessWidget {
}
}
class SliverGridDelegateWithFixedHeight extends SliverGridDelegate{
class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
const SliverGridDelegateWithFixedHeight({
required this.maxCrossAxisExtent,
required this.itemHeight,
@@ -58,23 +59,21 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate{
crossAxisStride: width / crossItems,
childMainAxisExtent: itemHeight,
childCrossAxisExtent: width / crossItems,
reverseCrossAxis: false
);
reverseCrossAxis: false);
}
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
if(oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
if(oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent
|| oldDelegate.itemHeight != itemHeight){
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
oldDelegate.itemHeight != itemHeight) {
return true;
}
return false;
}
}
class SliverGridDelegateWithComics extends SliverGridDelegate{
class SliverGridDelegateWithComics extends SliverGridDelegate {
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]);
final bool useBriefMode;
@@ -83,14 +82,17 @@ class SliverGridDelegateWithComics extends SliverGridDelegate{
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
if(appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode){
return getBriefModeLayout(constraints, scale ?? appdata.settings['comicTileScale']);
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) {
return getBriefModeLayout(
constraints, scale ?? appdata.settings['comicTileScale']);
} else {
return getDetailedModeLayout(constraints, scale ?? appdata.settings['comicTileScale']);
return getDetailedModeLayout(constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble());
}
}
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale){
SliverGridLayout getDetailedModeLayout(
SliverConstraints constraints, double scale) {
const minCrossAxisExtent = 360;
final itemHeight = 152 * scale;
final width = constraints.crossAxisExtent;
@@ -102,15 +104,17 @@ class SliverGridDelegateWithComics extends SliverGridDelegate{
crossAxisStride: width / crossItems,
childMainAxisExtent: itemHeight,
childCrossAxisExtent: width / crossItems,
reverseCrossAxis: false
);
reverseCrossAxis: false);
}
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale){
SliverGridLayout getBriefModeLayout(
SliverConstraints constraints, double scale) {
final maxCrossAxisExtent = 192.0 * scale;
const childAspectRatio = 0.72;
const crossAxisSpacing = 0.0;
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
int crossAxisCount =
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
.ceil();
// Ensure a minimum count of 1, can be zero and result in an infinite extent
// below when the window size is 0.
crossAxisCount = math.max(1, crossAxisCount);

View File

@@ -199,7 +199,7 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
int _page = 1;
int _maxPage = 1;
int? _maxPage;
Future<Res<List<S>>> loadData(int page);
@@ -211,10 +211,10 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
bool get isFirstLoading => _isFirstLoading;
bool get haveNextPage => _page <= _maxPage;
bool get haveNextPage => _maxPage == null || _page <= _maxPage!;
void nextPage() {
if (_page > _maxPage) return;
if (_maxPage != null && _page > _maxPage!) return;
if (_isLoading) return;
_isLoading = true;
loadData(_page).then((value) {

View File

@@ -88,7 +88,8 @@ class RandomCategoryPart extends BaseCategoryPart {
if (randomNumber >= tags.length) {
return tags;
}
return tags.sublist(math.Random().nextInt(tags.length - randomNumber));
var start = math.Random().nextInt(tags.length - randomNumber);
return tags.sublist(start, start + randomNumber);
}
@override

View File

@@ -197,6 +197,8 @@ class ComicSource {
final HandleClickTagEvent? handleClickTagEvent;
final LinkHandler? linkHandler;
Future<void> loadData() async {
var file = File("${App.dataPath}/comic_source/$key.data");
if (await file.exists()) {
@@ -261,14 +263,13 @@ class ComicSource {
this.idMatcher,
this.translations,
this.handleClickTagEvent,
this.linkHandler,
);
}
class AccountConfig {
final LoginFunction? login;
final FutureOr<void> Function(BuildContext)? onLogin;
final String? loginWebsite;
final String? registerWebsite;
@@ -279,10 +280,15 @@ class AccountConfig {
final List<AccountInfoItem> infoItems;
final bool Function(String url, String title)? checkLoginStatus;
const AccountConfig(
this.login, this.loginWebsite, this.registerWebsite, this.logout,
{this.onLogin})
: allowReLogin = true,
this.login,
this.loginWebsite,
this.registerWebsite,
this.logout,
this.checkLoginStatus,
) : allowReLogin = true,
infoItems = const [];
}
@@ -315,11 +321,13 @@ class ExplorePageData {
/// return a `List` contains `List<Comic>` or `ExplorePagePart`
final Future<Res<List<Object>>> Function(int index)? loadMixed;
final WidgetBuilder? overridePageBuilder;
ExplorePageData(this.title, this.type, this.loadPage, this.loadMultiPart)
: loadMixed = null,
overridePageBuilder = null;
ExplorePageData(
this.title,
this.type,
this.loadPage,
this.loadMultiPart,
this.loadMixed,
);
}
class ExplorePagePart {
@@ -422,3 +430,12 @@ class CategoryComicsOptions {
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
}
class LinkHandler {
final List<String> domains;
final String? Function(String url) linkToId;
const LinkHandler(this.domains, this.linkToId);
}

View File

@@ -11,11 +11,23 @@ class Comment {
final bool? isLiked;
final int? voteStatus; // 1: upvote, -1: downvote, 0: none
static String? parseTime(dynamic value) {
if(value == null) return null;
if(value is int) {
if(value < 10000000000) {
return DateTime.fromMillisecondsSinceEpoch(value * 1000).toString().substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(value).toString().substring(0, 19);
}
}
return value.toString();
}
Comment.fromJson(Map<String, dynamic> json)
: userName = json["userName"],
avatar = json["avatar"],
content = json["content"],
time = json["time"],
time = parseTime(json["time"]),
replyCount = json["replyCount"],
id = json["id"].toString(),
score = json["score"],
@@ -40,8 +52,19 @@ class Comic {
final int? maxPage;
const Comic(this.title, this.cover, this.id, this.subtitle, this.tags,
this.description, this.sourceKey, this.maxPage);
final String? language;
const Comic(
this.title,
this.cover,
this.id,
this.subtitle,
this.tags,
this.description,
this.sourceKey,
this.maxPage,
this.language,
);
Map<String, dynamic> toJson() {
return {
@@ -53,6 +76,7 @@ class Comic {
"description": description,
"sourceKey": sourceKey,
"maxPage": maxPage,
"language": language,
};
}
@@ -63,7 +87,8 @@ class Comic {
id = json["id"],
tags = List<String>.from(json["tags"] ?? []),
description = json["description"] ?? "",
maxPage = json["maxPage"];
maxPage = json["maxPage"],
language = json["language"];
}
class ComicDetails with HistoryMixin {
@@ -109,7 +134,7 @@ class ComicDetails with HistoryMixin {
final String? url;
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
var res = <String, List<String>>{};
map.forEach((key, value) {
res[key] = List<String>.from(value);

View File

@@ -147,6 +147,7 @@ class ComicSourceParser {
_parseIdMatch(),
_parseTranslation(),
_parseClickTagEvent(),
_parseLinkHandler(),
);
await source.loadData();
@@ -199,8 +200,28 @@ class ComicSourceParser {
JsEngine().runCode("ComicSource.sources.$_key.account.logout()");
}
return AccountConfig(login, _getValue("account.login.website"),
_getValue("account.registerWebsite"), logout);
if(!_checkExists('account.loginWithWebview')) {
return AccountConfig(
login,
null,
_getValue("account.registerWebsite"),
logout,
null,
);
} else {
return AccountConfig(
null,
_getValue("account.loginWithWebview.url"),
_getValue("account.registerWebsite"),
logout,
(url, title) {
return JsEngine().runCode("""
ComicSource.sources.$_key.account.loginWithWebview.checkStatus(
${jsonEncode(url)}, ${jsonEncode(title)})
""");
},
);
}
}
List<ExplorePageData> _loadExploreData() {
@@ -214,6 +235,7 @@ class ComicSourceParser {
final String type = _getValue("explore[$i].type");
Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
Future<Res<List<Comic>>> Function(int page)? loadPage;
Future<Res<List<Object>>> Function(int index)? loadMixed;
if (type == "singlePageWithMultiPart") {
loadMultiPart = () async {
try {
@@ -246,18 +268,69 @@ class ComicSourceParser {
return Res.error(e.toString());
}
};
} else if (type == "multiPartPage") {
loadMultiPart = () async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
return Res(
List.from(
(res as List).map((e) {
return ExplorePagePart(
e['title'],
(e['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
e['viewMore'],
);
}),
),
);
} catch (e, s) {
Log.error("Data Analysis", "$e\n$s");
return Res.error(e.toString());
}
};
} else if (type == 'mixed') {
loadMixed = (index) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
var list = <Object>[];
for (var data in (res['data'] as List)) {
if (data is List) {
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
} else if (data is Map) {
list.add(ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
));
}
}
return Res(list, subData: res['maxPage']);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
pages.add(ExplorePageData(
title,
switch (type) {
"singlePageWithMultiPart" =>
ExplorePageType.singlePageWithMultiPart,
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed,
_ =>
throw ComicSourceParseException("Unknown explore page type $type")
},
loadPage,
loadMultiPart));
loadMultiPart,
loadMixed,
));
}
return pages;
}
@@ -279,8 +352,11 @@ class ComicSourceParser {
final String type = c["type"];
final List<String> tags = List.from(c["categories"]);
final String itemType = c["itemType"];
final List<String>? categoryParams =
c["categoryParams"] == null ? null : List.from(c["categoryParams"]);
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
final String? groupParam = c["groupParam"];
if (groupParam != null) {
categoryParams = List.filled(tags.length, groupParam);
}
if (type == "fixed") {
categoryParts
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
@@ -407,6 +483,7 @@ class ComicSourceParser {
if (res is! Map<String, dynamic>) throw "Invalid data";
res['comicId'] = id;
res['sourceKey'] = _key;
JsEngine().clearHtml();
return Res(ComicDetails.fromJson(res));
} catch (e, s) {
Log.error("Network", "$e\n$s");
@@ -728,4 +805,18 @@ class ComicSourceParser {
return Map.from(r);
};
}
LinkHandler? _parseLinkHandler() {
if (!_checkExists("linkHandler")) {
return null;
}
List<String> domains = List.from(_getValue("link.domains"));
linkToId(String link) {
var res = JsEngine().runCode("""
ComicSource.sources.$_key.link.linkToId(${jsonEncode(link)})
""");
return res as String?;
}
return LinkHandler(domains, linkToId);
}
}

View File

@@ -225,6 +225,7 @@ class JsEngine with _JSEngineApi{
mixin class _JSEngineApi{
final Map<int, dom.Document> _documents = {};
final Map<int, dom.Element> _elements = {};
final Map<int, dom.Node> _nodes = {};
CookieJarSql? _cookieJar;
dynamic handleHtmlCallback(Map<String, dynamic> data) {
@@ -270,6 +271,38 @@ mixin class _JSEngineApi{
keys.add(_elements.length - 1);
}
return keys;
case "getNodes":
var res = _elements[data["key"]]!.nodes;
var keys = <int>[];
for (var node in res) {
_nodes[_nodes.length] = node;
keys.add(_nodes.length - 1);
}
return keys;
case "getInnerHTML":
return _elements[data["key"]]!.innerHtml;
case "getParent":
var res = _elements[data["key"]]!.parent;
if(res == null) return null;
_elements[_elements.length] = res;
return _elements.length - 1;
case "node_text":
return _nodes[data["key"]]!.text;
case "node_type":
return switch(_nodes[data["key"]]!.nodeType) {
dom.Node.ELEMENT_NODE => "element",
dom.Node.TEXT_NODE => "text",
dom.Node.COMMENT_NODE => "comment",
dom.Node.DOCUMENT_NODE => "document",
_ => "unknown"
};
case "node_to_element":
var node = _nodes[data["key"]]!;
if(node is dom.Element){
_elements[_elements.length] = node;
return _elements.length - 1;
}
return null;
}
}
@@ -305,9 +338,10 @@ mixin class _JSEngineApi{
}
}
void clear(){
void clearHtml(){
_documents.clear();
_elements.clear();
_nodes.clear();
}
void clearCookies(List<String> domains) async{

View File

@@ -120,6 +120,9 @@ class LocalComic with HistoryMixin implements Comic {
@override
String? get subTitle => subtitle;
@override
String? get language => null;
}
class LocalManager with ChangeNotifier {

View File

@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/main_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:window_manager/window_manager.dart';
import 'components/components.dart';
import 'components/window_frame.dart';
@@ -19,6 +20,9 @@ void main(List<String> args) {
runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
await init();
if(App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");

View File

@@ -1,10 +1,12 @@
import 'dart:io' as io;
import 'package:dio/dio.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/pages/webview.dart';
import 'package:venera/utils/ext.dart';
import 'cookie_jar.dart';
@@ -113,7 +115,9 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
);
}
if (App.isDesktop && (await DesktopWebview.isAvailable())) {
// windows version of package `flutter_inappwebview` cannot get some cookies
// Using DesktopWebview instead
if (App.isLinux || App.isWindows) {
var webview = DesktopWebview(
initialUrl: url,
onTitleChange: (title, controller) async {
@@ -136,12 +140,12 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
},
);
webview.open();
} else if (App.isMobile) {
} else {
await App.rootContext.to(
() => AppWebview(
initialUrl: url,
singlePage: true,
onTitleChange: (title, controller) async {
onLoadStop: (controller) async {
var res = await controller.platform.evaluateJavascript(
source:
"document.head.innerHTML.includes('#challenge-success-text')");
@@ -151,11 +155,11 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookiesMap = await controller.getCookies(url) ?? {};
if(cookiesMap['cf_clearance'] == null) {
var cookies = await controller.getCookies(url) ?? [];
if(cookies.firstWhereOrNull((element) => element.name == 'cf_clearance') == null) {
return;
}
saveCookies(cookiesMap);
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
App.rootPop();
}
},
@@ -165,13 +169,11 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookiesMap = await controller.getCookies(url) ?? {};
saveCookies(cookiesMap);
var cookies = await controller.getCookies(url) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
},
),
);
onFinished();
} else {
App.rootContext.showMessage(message: "Unsupported device");
}
}

View File

@@ -5,6 +5,8 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/webview.dart';
import 'package:venera/utils/translations.dart';
class AccountsPageLogic extends StateController {
@@ -60,18 +62,13 @@ class AccountsPage extends StatelessWidget {
title: Text("Log in".tl),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
if (element.account!.onLogin != null) {
await element.account!.onLogin!(context);
}
if (element.account!.login != null && context.mounted) {
await context.to(
() => _LoginPage(
login: element.account!.login!,
registerWebsite: element.account!.registerWebsite,
config: element.account!,
source: element,
),
);
element.saveData();
}
logic.update();
},
);
@@ -121,7 +118,7 @@ class AccountsPage extends StatelessWidget {
);
}
yield ListTile(
title: Text("Exit".tl),
title: Text("Log out".tl),
onTap: () {
element.data["account"] = null;
element.account?.logout();
@@ -146,11 +143,11 @@ class AccountsPage extends StatelessWidget {
}
class _LoginPage extends StatefulWidget {
const _LoginPage({required this.login, this.registerWebsite});
const _LoginPage({required this.config, required this.source});
final LoginFunction login;
final AccountConfig config;
final String? registerWebsite;
final ComicSource source;
@override
State<_LoginPage> createState() => _LoginPageState();
@@ -181,6 +178,7 @@ class _LoginPageState extends State<_LoginPage> {
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
enabled: widget.config.login != null,
onChanged: (s) {
username = s;
},
@@ -192,21 +190,39 @@ class _LoginPageState extends State<_LoginPage> {
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.login != null,
onChanged: (s) {
password = s;
},
onSubmitted: (s) => login(),
),
const SizedBox(height: 32),
if (widget.config.login == null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("Login with password is disabled".tl),
],
)
else
Button.filled(
isLoading: loading,
onPressed: login,
child: Text("Continue".tl),
),
const SizedBox(height: 32),
if (widget.registerWebsite != null)
const SizedBox(height: 24),
if (widget.config.loginWebsite != null)
FilledButton(
onPressed: loginWithWebview,
child: Text("Login with webview".tl),
),
const SizedBox(height: 8),
if (widget.config.registerWebsite != null)
TextButton(
onPressed: () => launchUrlString(widget.registerWebsite!),
onPressed: () =>
launchUrlString(widget.config.registerWebsite!),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -235,7 +251,7 @@ class _LoginPageState extends State<_LoginPage> {
setState(() {
loading = true;
});
widget.login(username, password).then((value) {
widget.config.login!(username, password).then((value) {
if (value.error) {
context.showMessage(message: value.errorMessage!);
setState(() {
@@ -248,4 +264,54 @@ class _LoginPageState extends State<_LoginPage> {
}
});
}
void loginWithWebview() async {
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
await context.to(
() => AppWebview(
initialUrl: widget.config.loginWebsite!,
onNavigation: (u, c) {
url = u;
print(url);
() async {
if (widget.config.checkLoginStatus != null) {
if (widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
App.mainNavigatorKey?.currentContext?.pop();
}
}
}();
return false;
},
onTitleChange: (t, c) {
() async {
if (widget.config.checkLoginStatus != null) {
if (widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
App.mainNavigatorKey?.currentContext?.pop();
}
}
}();
title = t;
},
),
);
if (success) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
}
}
}

View File

@@ -134,6 +134,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
height: 144,
@@ -369,11 +370,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildTag(text: comic.uploadTime!),
],
),
if (comic.uploadTime != null)
if (comic.updateTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comicSource.name),
buildTag(text: comic.updateTime!),
],
),
const SizedBox(height: 12),
@@ -1120,13 +1121,18 @@ class _FavoritePanelState extends State<_FavoritePanel> {
cid: widget.cid,
comicSource: comicSource,
isFavorite: widget.isFavorite,
onFavorite: widget.onFavorite,
);
}
}
class _NetworkFavorites extends StatefulWidget {
const _NetworkFavorites(
{required this.cid, required this.comicSource, required this.isFavorite});
const _NetworkFavorites({
required this.cid,
required this.comicSource,
required this.isFavorite,
required this.onFavorite,
});
final String cid;
@@ -1134,6 +1140,8 @@ class _NetworkFavorites extends StatefulWidget {
final bool? isFavorite;
final void Function(bool) onFavorite;
@override
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
}
@@ -1167,7 +1175,10 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite);
if (res.success) {
widget.onFavorite(!isFavorite);
context.pop();
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl);
} else {
setState(() {
isLoading = false;

View File

@@ -187,13 +187,6 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> {
comicSourceKey,
key: ValueKey(key),
);
} else if (data.overridePageBuilder != null) {
return Builder(
builder: (context) {
return data.overridePageBuilder!(context);
},
key: ValueKey(key),
);
} else {
return const Center(
child: Text("Empty Page"),

View File

@@ -121,6 +121,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"${e.time} | ${comicSource?.name ?? "Unknown"}",
comicSource?.key ?? "Unknown",
null,
null,
);
}).toList(),
menuBuilder: (c) {
@@ -202,6 +203,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
"${e.time} | ${comicSource?.name ?? "Unknown"}",
comicSource?.key ?? "Unknown",
null,
null,
),
);
},

View File

@@ -87,6 +87,7 @@ class _HistoryPageState extends State<HistoryPage> {
getDescription(e),
e.type.comicSource?.key ?? "Invalid",
null,
null,
);
},
).toList(),

View File

@@ -94,7 +94,12 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
const BackButton(),
const SizedBox(width: 8),
Expanded(
child: Text(context.reader.widget.name, style: ts.s18),
child: Text(
context.reader.widget.name,
style: ts.s18,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Tooltip(
@@ -356,7 +361,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context,
ReaderSettings(
onChanged: (key) {
if(key == "readerMode") {
if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop();
}

View File

@@ -27,7 +27,15 @@ class _SearchResultPageState extends State<SearchResultPage> {
late List<String> options;
void search([String? text]) {}
late String text;
void search([String? text]) {
if (text != null) {
setState(() {
this.text = text;
});
}
}
@override
void initState() {
@@ -37,12 +45,14 @@ class _SearchResultPageState extends State<SearchResultPage> {
);
sourceKey = widget.sourceKey;
options = widget.options;
text = widget.text;
super.initState();
}
@override
Widget build(BuildContext context) {
return ComicList(
key: Key(text + options.toString()),
errorLeading: AppSearchBar(
controller: controller,
),
@@ -52,7 +62,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
loadPage: (i) {
var source = ComicSource.find(sourceKey);
return source!.searchPageData!.loadPage!(
controller.initialText,
text,
i,
options,
);

View File

@@ -11,11 +11,13 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'dart:io' as io;
export 'package:flutter_inappwebview/flutter_inappwebview.dart' show WebUri, URLRequest;
export 'package:flutter_inappwebview/flutter_inappwebview.dart'
show WebUri, URLRequest;
extension WebviewExtension on InAppWebViewController{
Future<Map<String, String>?> getCookies(String url) async{
extension WebviewExtension on InAppWebViewController {
Future<List<io.Cookie>?> getCookies(String url) async {
if(url.contains("https://")){
url.replaceAll("https://", "");
}
@@ -24,18 +26,20 @@ extension WebviewExtension on InAppWebViewController{
}
CookieManager cookieManager = CookieManager.instance();
final cookies = await cookieManager.getCookies(url: WebUri(url));
Map<String, String> res = {};
for(var cookie in cookies){
res[cookie.name] = cookie.value;
var res = <io.Cookie>[];
for (var cookie in cookies) {
var c = io.Cookie(cookie.name, cookie.value);
c.domain = cookie.domain;
res.add(c);
}
return res;
}
Future<String?> getUA() async{
Future<String?> getUA() async {
var res = await evaluateJavascript(source: "navigator.userAgent");
if(res is String){
if(res[0] == "'" || res[0] == "\"") {
res = res.substring(1, res.length-1);
if (res is String) {
if (res[0] == "'" || res[0] == "\"") {
res = res.substring(1, res.length - 1);
}
}
return res is String ? res : null;
@@ -43,17 +47,27 @@ extension WebviewExtension on InAppWebViewController{
}
class AppWebview extends StatefulWidget {
const AppWebview({required this.initialUrl, this.onTitleChange,
this.onNavigation, this.singlePage = false, this.onStarted, super.key});
const AppWebview(
{required this.initialUrl,
this.onTitleChange,
this.onNavigation,
this.singlePage = false,
this.onStarted,
this.onLoadStop,
super.key});
final String initialUrl;
final void Function(String title, InAppWebViewController controller)? onTitleChange;
final void Function(String title, InAppWebViewController controller)?
onTitleChange;
final bool Function(String url)? onNavigation;
final bool Function(String url, InAppWebViewController controller)?
onNavigation;
final void Function(InAppWebViewController controller)? onStarted;
final void Function(InAppWebViewController controller)? onLoadStop;
final bool singlePage;
@override
@@ -74,20 +88,24 @@ class _AppWebviewState extends State<AppWebview> {
message: "More",
child: IconButton(
icon: const Icon(Icons.more_horiz),
onPressed: (){
showMenu(context: context, position: RelativeRect.fromLTRB(
onPressed: () {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
0,
MediaQuery.of(context).size.width,
0
), items: [
0),
items: [
PopupMenuItem(
child: Text("Open in browser".tl),
onTap: () async => launchUrlString((await controller?.getUrl())!.path),
onTap: () async =>
launchUrlString((await controller?.getUrl())!.path),
),
PopupMenuItem(
child: Text("Copy link".tl),
onTap: () async => Clipboard.setData(ClipboardData(text: (await controller?.getUrl())!.path)),
onTap: () async => Clipboard.setData(ClipboardData(
text: (await controller?.getUrl())!.path)),
),
PopupMenuItem(
child: Text("Reload".tl),
@@ -100,9 +118,12 @@ class _AppWebviewState extends State<AppWebview> {
];
Widget body = InAppWebView(
initialSettings: InAppWebViewSettings(
isInspectable: true,
),
initialUrlRequest: URLRequest(url: WebUri(widget.initialUrl)),
onTitleChanged: (c, t){
if(mounted){
onTitleChanged: (c, t) {
if (mounted) {
setState(() {
title = t ?? "Webview";
});
@@ -110,19 +131,24 @@ class _AppWebviewState extends State<AppWebview> {
widget.onTitleChange?.call(title, controller!);
},
shouldOverrideUrlLoading: (c, r) async {
var res = widget.onNavigation?.call(r.request.url?.toString() ?? "") ?? false;
if(res) {
var res =
widget.onNavigation?.call(r.request.url?.toString() ?? "", c) ??
false;
if (res) {
return NavigationActionPolicy.CANCEL;
} else {
return NavigationActionPolicy.ALLOW;
}
},
onWebViewCreated: (c){
onWebViewCreated: (c) {
controller = c;
widget.onStarted?.call(c);
},
onProgressChanged: (c, p){
if(mounted){
onLoadStop: (c, r) {
widget.onLoadStop?.call(c);
},
onProgressChanged: (c, p) {
if (mounted) {
setState(() {
_progress = p / 100;
});
@@ -133,19 +159,22 @@ class _AppWebviewState extends State<AppWebview> {
body = Stack(
children: [
Positioned.fill(child: body),
if(_progress < 1.0)
const Positioned.fill(child: Center(
child: CircularProgressIndicator()))
if (_progress < 1.0)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()))
],
);
return Scaffold(
appBar: Appbar(
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,),
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
actions: actions,
),
body: body
);
body: body);
}
}
@@ -162,13 +191,12 @@ class DesktopWebview {
final void Function()? onClose;
DesktopWebview({
required this.initialUrl,
DesktopWebview(
{required this.initialUrl,
this.onTitleChange,
this.onNavigation,
this.onStarted,
this.onClose
});
this.onClose});
Webview? _webview;
@@ -178,8 +206,8 @@ class DesktopWebview {
void onMessage(String message) {
var json = jsonDecode(message);
if(json is Map){
if(json["id"] == "document_created"){
if (json is Map) {
if (json["id"] == "document_created") {
title = json["data"]["title"];
_ua = json["data"]["ua"];
onTitleChange?.call(title!, this);
@@ -210,14 +238,15 @@ class DesktopWebview {
}
collect();
''';
if(_webview != null) {
if (_webview != null) {
onMessage(await evaluateJavascript(js) ?? '');
}
});
}
void open() async {
_webview = await WebviewWindow.create(configuration: CreateConfiguration(
_webview = await WebviewWindow.create(
configuration: CreateConfiguration(
useWindowPositionAndSize: true,
userDataFolderWindows: "${App.dataPath}\\webview",
title: "webview",
@@ -242,11 +271,11 @@ class DesktopWebview {
return _webview!.evaluateJavaScript(source);
}
Future<Map<String, String>> getCookies(String url) async{
Future<Map<String, String>> getCookies(String url) async {
var allCookies = await _webview!.getAllCookies();
var res = <String, String>{};
for(var c in allCookies) {
if(_cookieMatch(url, c.domain)){
for (var c in allCookies) {
if (_cookieMatch(url, c.domain)) {
res[_removeCode0(c.name)] = _removeCode0(c.value);
}
}

30
lib/utils/app_links.dart Normal file
View File

@@ -0,0 +1,30 @@
import 'package:app_links/app_links.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/comic_page.dart';
void handleLinks() {
final appLinks = AppLinks();
appLinks.uriLinkStream.listen((uri) {
handleAppLink(uri);
});
}
void handleAppLink(Uri uri) async {
for(var source in ComicSource.all()) {
if(source.linkHandler != null) {
if(source.linkHandler!.domains.contains(uri.host)) {
var id = source.linkHandler!.linkToId(uri.toString());
if(id != null) {
if(App.mainNavigatorKey == null) {
await Future.delayed(const Duration(milliseconds: 200));
}
App.mainNavigatorKey!.currentContext?.to(() {
return ComicPage(id: id, sourceKey: source.key);
});
}
return;
}
}
}
}

View File

@@ -8,6 +8,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <flutter_qjs/flutter_qjs_plugin.h>
#include <gtk/gtk_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -20,6 +21,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_qjs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterQjsPlugin");
flutter_qjs_plugin_register_with_registrar(flutter_qjs_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
flutter_qjs
gtk
screen_retriever
sqlite3_flutter_libs
url_launcher_linux

View File

@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import app_links
import desktop_webview_window
import flutter_inappwebview_macos
import path_provider_foundation
@@ -15,6 +16,7 @@ import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))

View File

@@ -1,6 +1,38 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
app_links:
dependency: "direct main"
description:
name: app_links
sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99
url: "https://pub.dev"
source: hosted
version: "6.3.2"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
async:
dependency: transitive
description:
@@ -255,6 +287,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
html:
dependency: "direct main"
description:

View File

@@ -48,6 +48,7 @@ dependencies:
url: https://github.com/wgh136/flutter_desktop_webview
path: packages/desktop_webview_window
flutter_inappwebview: ^6.1.5
app_links: ^6.3.2
dev_dependencies:
flutter_test:

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_qjs/flutter_qjs_plugin.h>
@@ -16,6 +17,8 @@
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
DesktopWebviewWindowPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
app_links
desktop_webview_window
flutter_inappwebview_windows
flutter_qjs