diff --git a/lib/ui/home/home_ui.dart b/lib/ui/home/home_ui.dart index 31139fc..05f8b14 100644 --- a/lib/ui/home/home_ui.dart +++ b/lib/ui/home/home_ui.dart @@ -527,7 +527,7 @@ class HomeUI extends HookConsumerWidget { if (touchKey != null) { AnalyticsApi.touch(touchKey); } - model.goWebView(webTitle, webURL, useLocalization: true); + model.goWebView(context, webTitle, webURL, useLocalization: true); }, child: Container( width: width, @@ -592,8 +592,8 @@ class HomeUI extends HookConsumerWidget { borderRadius: BorderRadius.circular(12), child: GestureDetector( onTap: () { - model.goWebView( - "RSI 服务器状态", "https://status.robertsspaceindustries.com/", + model.goWebView(context, "RSI 服务器状态", + "https://status.robertsspaceindustries.com/", useLocalization: true); }, child: Container( diff --git a/lib/ui/home/home_ui_model.dart b/lib/ui/home/home_ui_model.dart index 52ac3f1..50ff6cf 100644 --- a/lib/ui/home/home_ui_model.dart +++ b/lib/ui/home/home_ui_model.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:dart_rss/domain/rss_item.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -26,6 +27,8 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as html_dom; +import '../webview/webview.dart'; + part 'home_ui_model.freezed.dart'; part 'home_ui_model.g.dart'; @@ -116,8 +119,80 @@ class HomeUIModel extends _$HomeUIModel { onMenuTap(String key) {} - void goWebView(String webTitle, String webURL, - {required bool useLocalization}) {} + // ignore: avoid_build_context_in_providers + Future goWebView(BuildContext context, String title, String url, + {bool useLocalization = false, + bool loginMode = false, + RsiLoginCallback? rsiLoginCallback}) async { + if (useLocalization) { + const tipVersion = 2; + final box = await Hive.openBox("app_conf"); + final skip = + await box.get("skip_web_localization_tip_version", defaultValue: 0); + if (skip != tipVersion) { + if (!context.mounted) return; + final ok = await showConfirmDialogs( + context, + "星际公民网站汉化", + const Text( + "本插功能件仅供大致浏览使用,不对任何有关本功能产生的问题负责!在涉及账号操作前请注意确认网站的原本内容!" + "\n\n\n使用此功能登录账号时请确保您的 SC汉化盒子 是从可信任的来源下载。", + style: TextStyle(fontSize: 16), + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * .6)); + if (!ok) { + if (loginMode) { + rsiLoginCallback?.call(null, false); + } + return; + } + await box.put("skip_web_localization_tip_version", tipVersion); + } + } + if (!await WebviewWindow.isWebviewAvailable()) { + if (!context.mounted) return; + showToast(context, "需要安装 WebView2 Runtime"); + launchUrlString( + "https://developer.microsoft.com/en-us/microsoft-edge/webview2/"); + return; + } + if (!context.mounted) return; + final webViewModel = WebViewModel(context, + loginMode: loginMode, loginCallback: rsiLoginCallback); + if (useLocalization) { + state = state.copyWith(isFixing: true, isFixingString: "正在初始化汉化资源..."); + try { + await webViewModel.initLocalization(state.webLocalizationVersionsData!); + } catch (e) { + if (!context.mounted) return; + showToast(context, "初始化网页汉化资源失败!$e"); + } + state = state.copyWith(isFixingString: "", isFixing: false); + } + await webViewModel.initWebView( + title: title, + applicationSupportDir: appGlobalState.applicationSupportDir!, + appVersionData: appGlobalState.networkVersionData!, + ); + + // if (await File( + // "${AppConf.applicationSupportDir}\\webview_data\\enable_webview_localization_capture") + // .exists()) { + // webViewModel.enableCapture = true; + // BaseUIContainer( + // uiCreate: () => WebviewLocalizationCaptureUI(), + // modelCreate: () => + // WebviewLocalizationCaptureUIModel(webViewModel)) + // .push(context!) + // .then((_) { + // webViewModel.enableCapture = false; + // }); + // } + + await Future.delayed(const Duration(milliseconds: 500)); + await webViewModel.launch(url, appGlobalState.networkVersionData!); + } bool isRSIServerStatusOK(Map map) { return (map["status"] == "ok" || map["status"] == "operational"); diff --git a/lib/ui/webview/webview.dart b/lib/ui/webview/webview.dart new file mode 100644 index 0000000..8406608 --- /dev/null +++ b/lib/ui/webview/webview.dart @@ -0,0 +1,321 @@ +// ignore_for_file: use_build_context_synchronously + +import 'dart:async'; +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive/hive.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:starcitizen_doctor/common/conf/url_conf.dart'; +import 'package:starcitizen_doctor/common/io/rs_http.dart'; +import 'package:starcitizen_doctor/common/utils/base_utils.dart'; +import 'package:starcitizen_doctor/common/utils/log.dart'; +import 'package:starcitizen_doctor/common/win32/credentials.dart'; +import 'package:starcitizen_doctor/data/app_version_data.dart'; +import 'package:starcitizen_doctor/data/app_web_localization_versions_data.dart'; + +typedef RsiLoginCallback = void Function(Map? data, bool success); + +class WebViewModel { + late Webview webview; + final BuildContext context; + + bool _isClosed = false; + + bool get isClosed => _isClosed; + + WebViewModel(this.context, + {this.loginMode = false, this.loginCallback, this.loginChannel = "LIVE"}); + + String url = ""; + bool canGoBack = false; + + final localizationResource = {}; + + var localizationScript = ""; + + bool enableCapture = false; + + bool isEnableToolSiteMirrors = false; + + Map? _curReplaceWords; + + Map? get curReplaceWords => _curReplaceWords; + + final bool loginMode; + final String loginChannel; + + bool _loginModeSuccess = false; + + final RsiLoginCallback? loginCallback; + + initWebView( + {String title = "", + required String applicationSupportDir, + required AppVersionData appVersionData}) async { + try { + final userBox = await Hive.openBox("app_conf"); + isEnableToolSiteMirrors = + userBox.get("isEnableToolSiteMirrors", defaultValue: false); + webview = await WebviewWindow.create( + configuration: CreateConfiguration( + windowWidth: loginMode ? 960 : 1920, + windowHeight: loginMode ? 720 : 1080, + userDataFolderWindows: "$applicationSupportDir/webview_data", + title: title)); + // webview.openDevToolsWindow(); + webview.isNavigating.addListener(() async { + if (!webview.isNavigating.value && localizationResource.isNotEmpty) { + dPrint("webview Navigating url === $url"); + if (url.contains("robertsspaceindustries.com")) { + // SC 官网 + dPrint("load script"); + await Future.delayed(const Duration(milliseconds: 100)); + await webview.evaluateJavaScript(localizationScript); + dPrint("update replaceWords"); + final replaceWords = _getLocalizationResource("zh-CN"); + + const org = "https://robertsspaceindustries.com/orgs"; + const citizens = "https://robertsspaceindustries.com/citizens"; + const organization = + "https://robertsspaceindustries.com/account/organization"; + const concierge = + "https://robertsspaceindustries.com/account/concierge"; + const referral = + "https://robertsspaceindustries.com/account/referral-program"; + const address = + "https://robertsspaceindustries.com/account/addresses"; + + const hangar = "https://robertsspaceindustries.com/account/pledges"; + + const spectrum = + "https://robertsspaceindustries.com/spectrum/community/"; + // 跳过光谱论坛 https://github.com/StarCitizenToolBox/StarCitizenBoxBrowserEx/issues/1 + if (url.startsWith(spectrum)) { + return; + } + + if (url.startsWith(org) || + url.startsWith(citizens) || + url.startsWith(organization)) { + replaceWords.add({"word": 'members', "replacement": '名成员'}); + replaceWords.addAll(_getLocalizationResource("orgs")); + } + + if (address.startsWith(address)) { + replaceWords.addAll(_getLocalizationResource("address")); + } + + if (url.startsWith(referral)) { + replaceWords.addAll([ + {"word": 'Total recruits: ', "replacement": '总邀请数:'}, + {"word": 'Prospects ', "replacement": '未完成的邀请'}, + {"word": 'Recruits', "replacement": '已完成的邀请'}, + ]); + } + + if (url.startsWith(concierge)) { + replaceWords.clear(); + replaceWords.addAll(_getLocalizationResource("concierge")); + } + if (url.startsWith(hangar)) { + replaceWords.addAll(_getLocalizationResource("hangar")); + } + + _curReplaceWords = {}; + for (var element in replaceWords) { + _curReplaceWords?[element["word"] ?? ""] = + element["replacement"] ?? ""; + } + await Future.delayed(const Duration(milliseconds: 100)); + await webview.evaluateJavaScript( + "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)"); + + /// loginMode + if (loginMode) { + dPrint( + "--- do rsi login ---\n run === getRSILauncherToken(\"$loginChannel\");"); + await Future.delayed(const Duration(milliseconds: 200)); + webview.evaluateJavaScript( + "getRSILauncherToken(\"$loginChannel\");"); + } + } else if (url.startsWith(await _handleMirrorsUrl( + "https://www.erkul.games", appVersionData))) { + dPrint("load script"); + await Future.delayed(const Duration(milliseconds: 100)); + await webview.evaluateJavaScript(localizationScript); + dPrint("update replaceWords"); + final replaceWords = _getLocalizationResource("DPS"); + await webview.evaluateJavaScript( + "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)"); + } else if (url.startsWith(await _handleMirrorsUrl( + "https://uexcorp.space", appVersionData))) { + dPrint("load script"); + await Future.delayed(const Duration(milliseconds: 100)); + await webview.evaluateJavaScript(localizationScript); + dPrint("update replaceWords"); + final replaceWords = _getLocalizationResource("UEX"); + await webview.evaluateJavaScript( + "WebLocalizationUpdateReplaceWords(${json.encode(replaceWords)},$enableCapture)"); + } + } + }); + webview.addOnUrlRequestCallback((url) { + dPrint("OnUrlRequestCallback === $url"); + this.url = url; + }); + webview.onClose.whenComplete(dispose); + if (loginMode) { + webview.addOnWebMessageReceivedCallback((messageString) { + final message = json.decode(messageString); + if (message["action"] == "webview_rsi_login_show_window") { + webview.setWebviewWindowVisibility(true); + _checkAutoLogin(webview); + } else if (message["action"] == "webview_rsi_login_success") { + _loginModeSuccess = true; + loginCallback?.call(message, true); + webview.close(); + } + }); + Future.delayed(const Duration(seconds: 1)) + .then((value) => {webview.setWebviewWindowVisibility(false)}); + } + } catch (e) { + showToast(context, "初始化失败:$e"); + } + } + + Future _handleMirrorsUrl( + String url, AppVersionData appVersionData) async { + var finalUrl = url; + if (isEnableToolSiteMirrors) { + for (var kv in appVersionData.webMirrors!.entries) { + if (url.startsWith(kv.key)) { + finalUrl = url.replaceFirst(kv.key, kv.value); + } + } + } + return finalUrl; + } + + launch(String url, AppVersionData appVersionData) async { + webview.launch(await _handleMirrorsUrl(url, appVersionData)); + } + + initLocalization(AppWebLocalizationVersionsData v) async { + localizationScript = await rootBundle.loadString('assets/web_script.js'); + + /// https://github.com/CxJuice/Uex_Chinese_Translate + // get versions + final hostUrl = URLConf.webTranslateHomeUrl; + dPrint("AppWebLocalizationVersionsData === ${v.toJson()}"); + + localizationResource["zh-CN"] = await _getJson("$hostUrl/zh-CN-rsi.json", + cacheKey: "rsi", version: v.rsi); + localizationResource["concierge"] = await _getJson( + "$hostUrl/concierge.json", + cacheKey: "concierge", + version: v.concierge); + localizationResource["orgs"] = + await _getJson("$hostUrl/orgs.json", cacheKey: "orgs", version: v.orgs); + localizationResource["address"] = await _getJson("$hostUrl/addresses.json", + cacheKey: "addresses", version: v.addresses); + localizationResource["hangar"] = await _getJson("$hostUrl/hangar.json", + cacheKey: "hangar", version: v.hangar); + localizationResource["UEX"] = await _getJson("$hostUrl/zh-CN-uex.json", + cacheKey: "uex", version: v.uex); + localizationResource["DPS"] = await _getJson("$hostUrl/zh-CN-dps.json", + cacheKey: "dps", version: v.dps); + } + + List> _getLocalizationResource(String key) { + final List> localizations = []; + final dict = localizationResource[key]; + if (dict is Map) { + for (var element in dict.entries) { + final k = element.key + .toString() + .trim() + .toLowerCase() + .replaceAll(RegExp("/\xa0/g"), ' ') + .replaceAll(RegExp("/s{2,}/g"), ' '); + localizations + .add({"word": k, "replacement": element.value.toString().trim()}); + } + } + return localizations; + } + + Future _getJson(String url, + {String cacheKey = "", String? version}) async { + final box = await Hive.openBox("web_localization_cache_data"); + if (cacheKey.isNotEmpty) { + final localVersion = box.get("${cacheKey}_version}", defaultValue: ""); + var data = box.get(cacheKey, defaultValue: {}); + if (data is Map && data.isNotEmpty && localVersion == version) { + return data; + } + } + final startTime = DateTime.now(); + final r = await RSHttp.getText(url); + final endTime = DateTime.now(); + final data = json.decode(r); + if (cacheKey.isNotEmpty) { + dPrint( + "update $cacheKey v == $version time == ${(endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch) / 1000 / 1000}s"); + await box.put(cacheKey, data); + await box.put("${cacheKey}_version}", version); + } + return data; + } + + void addOnWebMessageReceivedCallback(OnWebMessageReceivedCallback callback) { + webview.addOnWebMessageReceivedCallback(callback); + } + + void removeOnWebMessageReceivedCallback( + OnWebMessageReceivedCallback callback) { + webview.removeOnWebMessageReceivedCallback(callback); + } + + FutureOr dispose() { + if (loginMode && !_loginModeSuccess) { + loginCallback?.call(null, false); + } + _isClosed = true; + } + + Future _checkAutoLogin(Webview webview) async { + final LocalAuthentication localAuth = LocalAuthentication(); + if (!await localAuth.isDeviceSupported()) return; + + final userBox = await Hive.openBox("rsi_account_data"); + final email = await userBox.get("account_email", defaultValue: ""); + + final pwdE = await userBox.get("account_pwd_encrypted", defaultValue: ""); + final nonceStr = await userBox.get("nonce", defaultValue: ""); + final macStr = await userBox.get("mac", defaultValue: ""); + if (email == "") return; + webview.evaluateJavaScript("RSIAutoLogin(\"$email\",\"\")"); + if (pwdE != "" && nonceStr != "" && macStr != "") { + // send toast + webview.evaluateJavaScript("SCTShowToast(\"请完成 Windows Hello 验证以填充密码\")"); + // decrypt + if (await localAuth.authenticate(localizedReason: "请输入设备PIN以自动登录RSI账户") != + true) return; + final kv = Win32Credentials.read("SCToolbox_RSI_Account_secret"); + if (kv == null || kv.key != email) return; + + final algorithm = AesGcm.with256bits(); + final r = await algorithm.decrypt( + SecretBox(base64.decode(pwdE), + nonce: base64.decode(nonceStr), mac: Mac(base64.decode(macStr))), + secretKey: SecretKey(base64.decode(kv.value))); + final decryptedPwd = utf8.decode(r); + webview.evaluateJavaScript("RSIAutoLogin(\"$email\",\"$decryptedPwd\")"); + } + } +}