From b00318a0fceac1050bce744d9273c62e509255b0 Mon Sep 17 00:00:00 2001 From: Travis Smith Date: Sat, 24 May 2025 16:39:30 -0400 Subject: [PATCH 1/4] Adds @grant to model for userscripts * Implements GM.getValue / GM.setValue and that's it so far. * Needs some work, but I think from some basic testing seems to be working alright. * Access is scoped to each script. --- .gitignore | 1 + assets/userscripts/apis/GM.getValue.js | 3 + assets/userscripts/apis/GM.setValue.js | 3 + assets/userscripts/apis/GM_getValue.js | 3 + assets/userscripts/apis/GM_setValue.js | 3 + assets/userscripts/apis/default.js | 1 + lib/models/userscript_model.dart | 123 ++++++++++---- lib/providers/userscripts_apis_provider.dart | 38 +++++ lib/providers/userscripts_provider.dart | 152 +++++++++++------- pubspec.yaml | 2 + test/models/userscript_model_test.dart | 54 +++++++ .../userscripts_apis_provider_test.dart | 23 +++ 12 files changed, 310 insertions(+), 96 deletions(-) create mode 100644 assets/userscripts/apis/GM.getValue.js create mode 100644 assets/userscripts/apis/GM.setValue.js create mode 100644 assets/userscripts/apis/GM_getValue.js create mode 100644 assets/userscripts/apis/GM_setValue.js create mode 100644 assets/userscripts/apis/default.js create mode 100644 lib/providers/userscripts_apis_provider.dart create mode 100644 test/models/userscript_model_test.dart create mode 100644 test/providers/userscripts_apis_provider_test.dart diff --git a/.gitignore b/.gitignore index cbbcf5d3..ca49d2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ firebase.json **/ios/Flutter/flutter_assets/ **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Flutter/ephemeral/ # Windows **/windows/flutter/ephemeral/ diff --git a/assets/userscripts/apis/GM.getValue.js b/assets/userscripts/apis/GM.getValue.js new file mode 100644 index 00000000..080bd9bb --- /dev/null +++ b/assets/userscripts/apis/GM.getValue.js @@ -0,0 +1,3 @@ +GM.getValue = function(key, defaultValue) { + return localStorage.getItem(key) ?? defaultValue; +} \ No newline at end of file diff --git a/assets/userscripts/apis/GM.setValue.js b/assets/userscripts/apis/GM.setValue.js new file mode 100644 index 00000000..64d15c1e --- /dev/null +++ b/assets/userscripts/apis/GM.setValue.js @@ -0,0 +1,3 @@ +GM.setValue = function(key, value) { + localStorage.setItem(key, value); +} \ No newline at end of file diff --git a/assets/userscripts/apis/GM_getValue.js b/assets/userscripts/apis/GM_getValue.js new file mode 100644 index 00000000..12d6070d --- /dev/null +++ b/assets/userscripts/apis/GM_getValue.js @@ -0,0 +1,3 @@ +var GM_getValue = function(key, defaultValue) { + return localStorage.getItem(key) ?? defaultValue; +} \ No newline at end of file diff --git a/assets/userscripts/apis/GM_setValue.js b/assets/userscripts/apis/GM_setValue.js new file mode 100644 index 00000000..ca0f1b94 --- /dev/null +++ b/assets/userscripts/apis/GM_setValue.js @@ -0,0 +1,3 @@ +var GM_setValue = function(key, value) { + localStorage.setItem(key, value); +} \ No newline at end of file diff --git a/assets/userscripts/apis/default.js b/assets/userscripts/apis/default.js new file mode 100644 index 00000000..32237019 --- /dev/null +++ b/assets/userscripts/apis/default.js @@ -0,0 +1 @@ +var GM = {}; \ No newline at end of file diff --git a/lib/models/userscript_model.dart b/lib/models/userscript_model.dart index 6d97b57e..292e958d 100644 --- a/lib/models/userscript_model.dart +++ b/lib/models/userscript_model.dart @@ -6,9 +6,11 @@ import 'dart:convert'; import "package:http/http.dart" as http; -UserScriptModel userScriptModelFromJson(String str) => UserScriptModel.fromJson(json.decode(str)); +UserScriptModel userScriptModelFromJson(String str) => + UserScriptModel.fromJson(json.decode(str)); -String userScriptModelToJson(UserScriptModel data) => json.encode(data.toJson()); +String userScriptModelToJson(UserScriptModel data) => + json.encode(data.toJson()); enum UserScriptTime { start, end } @@ -22,19 +24,20 @@ enum UserScriptUpdateStatus { } class UserScriptModel { - UserScriptModel({ - this.enabled = true, - this.matches = const ["*"], - required this.name, - this.version = "0.0.0", - this.edited = false, - required this.source, - this.time = UserScriptTime.end, - this.url, - this.updateStatus = UserScriptUpdateStatus.noRemote, - required this.isExample, - this.customApiKey = "", - this.customApiKeyCandidate = false, + UserScriptModel( + {this.enabled = true, + this.matches = const ["*"], + required this.name, + this.version = "0.0.0", + this.edited = false, + required this.source, + this.time = UserScriptTime.end, + this.url, + this.updateStatus = UserScriptUpdateStatus.noRemote, + required this.isExample, + this.customApiKey = "", + this.customApiKeyCandidate = false, + this.grants = const [] }); bool enabled; @@ -49,23 +52,31 @@ class UserScriptModel { bool isExample; String customApiKey; bool customApiKeyCandidate; + List grants; factory UserScriptModel.fromJson(Map json) { // First check if is old model if (json["exampleCode"] is int) { final bool enabled = json["enabled"] is bool ? json["enabled"] : true; final String source = json["source"] is String ? json["source"] : ""; - final List matches = json["urls"] is List ? json["urls"].cast() : tryGetMatches(source); + final List matches = json["urls"] is List + ? json["urls"].cast() + : tryGetMatches(source); final String name = json["name"] is String ? json["name"] : "Unknown"; - final String version = json["version"] is String ? json["version"] : tryGetVersion(source) ?? "0.0.0"; + final String version = json["version"] is String + ? json["version"] + : tryGetVersion(source) ?? "0.0.0"; final bool edited = json["edited"] is bool ? json["edited"] : false; - final UserScriptTime time = json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end; - final bool isExample = json["isExample"] ?? (json["exampleCode"] ?? 0) > 0; + final UserScriptTime time = + json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end; + final bool isExample = + json["isExample"] ?? (json["exampleCode"] ?? 0) > 0; final url = json["url"] is String ? json["url"] - : tryGetUrl(json["source"]) ?? (isExample ? exampleScriptURLs[json["exampleCode"] - 1] : null); - final updateStatus = - UserScriptUpdateStatus.values.byName(json["updateStatus"] ?? (url is String ? "upToDate" : "noRemote")); + : tryGetUrl(json["source"]) ?? + (isExample ? exampleScriptURLs[json["exampleCode"] - 1] : null); + final updateStatus = UserScriptUpdateStatus.values.byName( + json["updateStatus"] ?? (url is String ? "upToDate" : "noRemote")); return UserScriptModel( enabled: enabled, matches: matches, @@ -77,21 +88,29 @@ class UserScriptModel { url: url, updateStatus: updateStatus, isExample: isExample, + grants: [] // Old model does not have grants ); } else { return UserScriptModel( enabled: json["enabled"], - matches: json["matches"] is List ? json["matches"].cast() : const ["*"], + matches: json["matches"] is List + ? json["matches"].cast() + : const ["*"], name: json["name"], version: json["version"], edited: json["edited"], source: json["source"], - time: json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end, + time: + json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end, url: json["url"], - updateStatus: UserScriptUpdateStatus.values.byName(json["updateStatus"] ?? "noRemote"), + updateStatus: UserScriptUpdateStatus.values + .byName(json["updateStatus"] ?? "noRemote"), isExample: json["isExample"] ?? (json["exampleCode"] ?? 0) > 0, customApiKey: json["customApiKey"] ?? "", customApiKeyCandidate: json["customApiKeyCandidate"] ?? false, + grants: json["grants"] is List + ? json["grants"].cast() + : const [], ); } } @@ -119,14 +138,19 @@ class UserScriptModel { matches: metaMap["matches"] ?? ["*"], url: url ?? metaMap["downloadURL"], updateStatus: updateStatus, - time: time ?? (metaMap["injectionTime"] == "document-start" ? UserScriptTime.start : UserScriptTime.end), + time: time ?? + (metaMap["injectionTime"] == "document-start" + ? UserScriptTime.start + : UserScriptTime.end), isExample: isExample ?? false, customApiKey: customApiKey ?? "", customApiKeyCandidate: customApiKeyCandidate ?? false, + grants: metaMap["grant"] ?? [], ); } - static Future<({bool success, String message, UserScriptModel? model})> fromURL(String url, {bool? isExample}) async { + static Future<({bool success, String message, UserScriptModel? model})> + fromURL(String url, {bool? isExample}) async { try { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { @@ -135,7 +159,9 @@ class UserScriptModel { success: true, message: "Success", model: UserScriptModel.fromMetaMap(metaMap, - url: url, updateStatus: UserScriptUpdateStatus.upToDate, isExample: isExample ?? false), + url: url, + updateStatus: UserScriptUpdateStatus.upToDate, + isExample: isExample ?? false), ); } else { return ( @@ -162,7 +188,8 @@ class UserScriptModel { final List version1List = version1.split("."); final List version2List = version2.split("."); for (int i = 0; i < version1List.length; i++) { - if (version2List.length <= i || int.parse(version1List[i]) > int.parse(version2List[i])) { + if (version2List.length <= i || + int.parse(version1List[i]) > int.parse(version2List[i])) { return true; } } @@ -179,6 +206,7 @@ class UserScriptModel { "url": url, "updateStatus": updateStatus.name, "isExample": isExample, + "grants": grants, "time": time == UserScriptTime.start ? "start" : "end", "customApiKey": customApiKey, "customApiKeyCandidate": customApiKeyCandidate, @@ -186,13 +214,16 @@ class UserScriptModel { static Map parseHeader(String source) { // Thanks to [ViolentMonkey](https://github.com/violentmonkey/violentmonkey) for the following two regexes - String? meta = - RegExp(r"((?:^|\n)\s*\/\/\x20==UserScript==)([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$").stringMatch(source); + String? meta = RegExp( + r"((?:^|\n)\s*\/\/\x20==UserScript==)([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$") + .stringMatch(source); if (meta == null || meta.isEmpty) { throw Exception("No header found in userscript."); } - Iterable metaMatches = RegExp(r"^(?:^|\n)\s*\/\/\x20(@\S+)(.*)$", multiLine: true).allMatches(meta); - Map metaMap = {"@match": []}; + Iterable metaMatches = + RegExp(r"^(?:^|\n)\s*\/\/\x20(@\S+)(.*)$", multiLine: true) + .allMatches(meta); + Map metaMap = {"@match": [], "@grant": []}; for (final match in metaMatches) { if (match.groupCount < 2) { continue; @@ -202,6 +233,11 @@ class UserScriptModel { } if (match.group(1)?.toLowerCase() == "@match") { metaMap["@match"].add(match.group(2)!.trim()); + } else if (match.group(1)?.toLowerCase() == "@grant") { + if (match.group(2)?.trim() == "none") { + continue; + } + metaMap["@grant"].add(match.group(2)!.trim()); } else { metaMap[match.group(1)!.trim().toLowerCase()] = match.group(2)!.trim(); } @@ -214,6 +250,7 @@ class UserScriptModel { "injectionTime": metaMap["@run-at"] ?? "document-end", "downloadURL": metaMap["@downloadurl"], "updateURL": metaMap["@updateurl"], + "grant": metaMap["@grant"], "source": source, }; } @@ -221,7 +258,8 @@ class UserScriptModel { shouldInject(String url, [UserScriptTime? time]) => enabled && (this.time == time || time == null) && - matches.any((match) => (match == "*" || url.contains(match.replaceAll("*", "")))); + matches.any( + (match) => (match == "*" || url.contains(match.replaceAll("*", "")))); void update({ bool? enabled, @@ -234,6 +272,7 @@ class UserScriptModel { String? url, String? customApiKey, bool? customApiKeyCandidate, + List? grants, required UserScriptUpdateStatus updateStatus, }) { if (source != null) { @@ -246,11 +285,16 @@ class UserScriptModel { if (metaMap["matches"] != null) { this.matches = metaMap["matches"]; } + if (metaMap["grant"] != null) { + this.grants = metaMap["grant"]; + } if (metaMap["name"] != null) { this.name = metaMap["name"]; } if (metaMap["injectionTime"] != null) { - this.time = metaMap["injectionTime"] == "document-start" ? UserScriptTime.start : UserScriptTime.end; + this.time = metaMap["injectionTime"] == "document-start" + ? UserScriptTime.start + : UserScriptTime.end; } if (metaMap["downloadURL"] != null) { this.url = metaMap["downloadURL"]; @@ -319,6 +363,15 @@ class UserScriptModel { } } + static List tryGetGrants(String source) { + try { + final metaMap = UserScriptModel.parseHeader(source); + return metaMap["grant"] ?? []; + } catch (e) { + return const []; + } + } + static String? tryGetUrl(String source) { try { final metaMap = UserScriptModel.parseHeader(source); diff --git a/lib/providers/userscripts_apis_provider.dart b/lib/providers/userscripts_apis_provider.dart new file mode 100644 index 00000000..d262e684 --- /dev/null +++ b/lib/providers/userscripts_apis_provider.dart @@ -0,0 +1,38 @@ +import 'dart:async' show Future; +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; + +class UserscriptApisProvider { + static final Future> _apis = _getApisMap(_getApis()); + + static Future> get apis async { + return _apis; + } + + static Future> _getApisMap( + Future> apiEntries) async { + return apiEntries.then((fileList) async { + return { + for (var file in fileList) + _mapFileNameToApiName(file): await rootBundle.loadString(file) + }; + }); + } + + static String _mapFileNameToApiName(String fileName) { + return fileName + .replaceFirst('assets/userscripts/apis/', '') + .replaceFirst('.js', ''); + } + + static Future> _getApis() async { + final manifestContent = await rootBundle.loadString('AssetManifest.json'); + + final Map manifestMap = json.decode(manifestContent); + + return manifestMap.keys + .where((String key) => key.startsWith('assets/userscripts/apis/')) + .where((String key) => key.endsWith('.js')) + .toList(); + } +} diff --git a/lib/providers/userscripts_provider.dart b/lib/providers/userscripts_provider.dart index 5491e4b4..95fcefe8 100644 --- a/lib/providers/userscripts_provider.dart +++ b/lib/providers/userscripts_provider.dart @@ -13,6 +13,7 @@ import 'package:torn_pda/main.dart'; // Project imports: import 'package:torn_pda/models/userscript_model.dart'; +import 'package:torn_pda/providers/userscripts_apis_provider.dart'; import 'package:torn_pda/utils/js_handlers.dart'; import 'package:torn_pda/utils/shared_prefs.dart'; // import 'package:torn_pda/utils/userscript_examples.dart'; @@ -94,32 +95,26 @@ class UserScriptsProvider extends ChangeNotifier { required String pdaApiKey, required UserScriptTime time, }) { - if (_userScriptsEnabled) { - try { - return UnmodifiableListView( - _userScriptList.where((s) => s.shouldInject(url, time)).map( - (s) { - return UserScript( - groupName: s.name, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - // If the script is a custom API key script, we need to replace the API key - source: adaptSource( - source: s.source, - scriptFinalApiKey: s.customApiKey.isNotEmpty ? s.customApiKey : pdaApiKey, - ), - ); - }, - ), - ); - } catch (e, trace) { - if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log("PDA error at userscripts getCondSources. Error: $e"); - } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); - logToUser("PDA error at userscripts getCondSources. Error: $e"); - } + if (!_userScriptsEnabled) { + return UnmodifiableListView(const []); } - return UnmodifiableListView(const []); + + return UnmodifiableListView( + _userScriptList.where((s) => s.shouldInject(url, time)) + .map((s) { + return UserScript( + groupName: s.name, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + // If the script is a custom API key script, we need to replace the API key + source: await adaptSource( + source: s.source, + scriptFinalApiKey: s.customApiKey.isNotEmpty ? s.customApiKey : pdaApiKey, + grants: s.grants, + ), + ); + }, + ), + ); } List getScriptsToRemove({ @@ -128,23 +123,46 @@ class UserScriptsProvider extends ChangeNotifier { if (!_userScriptsEnabled) { return const []; } else { - return _userScriptList.where((s) => !s.shouldInject(url)).map((s) => s.name).toList(); + return _userScriptList + .where((s) => !s.shouldInject(url)) + .map((s) => s.name) + .toList(); } } - String adaptSource({required String source, required String scriptFinalApiKey}) { + Future adaptSource({ + required String source, + required String scriptFinalApiKey, + required List? grants + }) async { final String withApiKey = source.replaceAll("###PDA-APIKEY###", scriptFinalApiKey); - String anonFunction = "(function() {$withApiKey}());"; + var apis = await UserscriptApisProvider.apis; + + // default includes the GM object and other stuff that always appears + String grantInjections = apis["default"] ?? ""; + + // each item that can be granted is checked and injected + for (final api in apis.entries) { + if (grants?.contains(api.key) ?? false) { + grantInjections += api.value; + } + } + String anonFunction = "(function() {$grantInjections;$withApiKey}());"; anonFunction = anonFunction.replaceAll('“', '"'); anonFunction = anonFunction.replaceAll('”', '"'); return anonFunction; } - Future<({bool success, String? message})> addUserScriptFromURL(String url, {bool? isExample}) async { + Future<({bool success, String? message})> addUserScriptFromURL(String url, + {bool? isExample}) async { final response = await UserScriptModel.fromURL(url, isExample: isExample); if (response.success && response.model != null) { - if (_userScriptList.any((script) => script.name == response.model!.name)) { - return (success: false, message: "Script with same name already exists"); + if (_userScriptList + .any((script) => script.name == response.model!.name)) { + return ( + success: false, + message: "Script with same name already exists" + ); } userScriptList.add(response.model!); _sort(); @@ -177,6 +195,7 @@ class UserScriptsProvider extends ChangeNotifier { List? matches, String? customApiKey, bool? customApiKeyCandidate, + List? grants, }) { final newScript = UserScriptModel( name: name, @@ -191,6 +210,7 @@ class UserScriptsProvider extends ChangeNotifier { isExample: isExample, customApiKey: customApiKey ?? "", customApiKeyCandidate: customApiKeyCandidate ?? false, + grants: grants ?? const [], ); userScriptList.add(newScript); @@ -213,25 +233,22 @@ class UserScriptsProvider extends ChangeNotifier { String? customApiKey, bool? customApiKeyCandidate, ) { - List? matches; bool couldParseHeader = true; - try { - matches = UserScriptModel.tryGetMatches(source); - } catch (e) { - matches ??= const ["*"]; - } - userScriptList.firstWhere((script) => script.name == editedModel.name).update( - name: name, - time: time, - source: source, - customApiKey: customApiKey ?? "", - customApiKeyCandidate: customApiKeyCandidate ?? false, - matches: matches, - updateStatus: isFromRemote - ? UserScriptUpdateStatus.upToDate - : editedModel.updateStatus == UserScriptUpdateStatus.noRemote - ? UserScriptUpdateStatus.noRemote - : UserScriptUpdateStatus.localModified); + List? matches = UserScriptModel.tryGetMatches(source); + List? grants = UserScriptModel.tryGetGrants(source); + userScriptList + .firstWhere((script) => script.name == editedModel.name) + .update( + name: name, + time: time, + source: source, + matches: matches, + grants: grants, + updateStatus: isFromRemote + ? UserScriptUpdateStatus.upToDate + : editedModel.updateStatus == UserScriptUpdateStatus.noRemote + ? UserScriptUpdateStatus.noRemote + : UserScriptUpdateStatus.localModified); notifyListeners(); _saveUserScriptsListSharedPrefs(); return couldParseHeader; @@ -306,13 +323,17 @@ class UserScriptsProvider extends ChangeNotifier { updateStatus: decodedModel.updateStatus, url: decodedModel.url, matches: decodedModel.matches, + grants: decodedModel.grants, ); } catch (e, trace) { if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log("PDA error at adding server userscript. Error: $e. Stack: $trace"); + FirebaseCrashlytics.instance.log( + "PDA error at adding server userscript. Error: $e. Stack: $trace"); } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); - logToUser("PDA error at adding server userscript. Error: $e. Stack: $trace"); + if (!Platform.isWindows) + FirebaseCrashlytics.instance.recordError(e, trace); + logToUser( + "PDA error at adding server userscript. Error: $e. Stack: $trace"); } } _sort(); @@ -326,7 +347,8 @@ class UserScriptsProvider extends ChangeNotifier { } void _sort() { - _userScriptList.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + _userScriptList + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } void changeScriptsFirstTime(bool value) { @@ -356,7 +378,8 @@ class UserScriptsProvider extends ChangeNotifier { // Check if the script with the same name already exists in the list // (user-reported bug) final String name = decodedModel.name.toLowerCase(); - if (_userScriptList.any((script) => script.name.toLowerCase() == name)) continue; + if (_userScriptList + .any((script) => script.name.toLowerCase() == name)) continue; bool customApiKeyCandidate = false; if (decodedModel.source.contains("###PDA-APIKEY###")) { @@ -379,10 +402,13 @@ class UserScriptsProvider extends ChangeNotifier { ); } catch (e, trace) { if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log("PDA error at adding one userscript. Error: $e. Stack: $trace"); + FirebaseCrashlytics.instance.log( + "PDA error at adding one userscript. Error: $e. Stack: $trace"); } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); - logToUser("PDA error at adding one userscript. Error: $e. Stack: $trace"); + if (!Platform.isWindows) + FirebaseCrashlytics.instance.recordError(e, trace); + logToUser( + "PDA error at adding one userscript. Error: $e. Stack: $trace"); } } } @@ -395,9 +421,11 @@ class UserScriptsProvider extends ChangeNotifier { } catch (e, trace) { // Pass (scripts will be empty) if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log("PDA error at userscripts first load. Error: $e. Stack: $trace"); + FirebaseCrashlytics.instance.log( + "PDA error at userscripts first load. Error: $e. Stack: $trace"); } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); + if (!Platform.isWindows) + FirebaseCrashlytics.instance.recordError(e, trace); logToUser("PDA error at userscript first load. Error: $e. Stack: $trace"); } } @@ -406,7 +434,8 @@ class UserScriptsProvider extends ChangeNotifier { int updates = 0; await Future.wait(_userScriptList.map((s) { // Only check for updates on relevant scripts - if (s.updateStatus == UserScriptUpdateStatus.localModified || s.updateStatus == UserScriptUpdateStatus.noRemote) { + if (s.updateStatus == UserScriptUpdateStatus.localModified || + s.updateStatus == UserScriptUpdateStatus.noRemote) { return Future.value(); } // Ensure script has a valid URL @@ -436,7 +465,8 @@ class UserScriptsProvider extends ChangeNotifier { userScriptList.removeWhere((s) => s.isExample); await Future.wait(defaultScriptUrls.map((url) => url == null ? Future.value() - : addUserScriptFromURL(url, isExample: true).then((r) => r.success ? added++ : failed++))); + : addUserScriptFromURL(url, isExample: true) + .then((r) => r.success ? added++ : failed++))); return ( added: added, failed: failed, diff --git a/pubspec.yaml b/pubspec.yaml index 7794dc17..8a3a71ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -148,6 +148,7 @@ dev_dependencies: sdk: flutter json_serializable: ^6.8.0 # Used by swagger_dart_code_generator swagger_dart_code_generator: ^3.0.1 + test: ^1.25.15 flutter_icons: android: true @@ -187,6 +188,7 @@ flutter: - userscripts/TornPDA_Ready.js - userscripts/TornPDA_API.js - userscripts/TornPDA_EvaluateJavascript.js + - assets/userscripts/apis/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/test/models/userscript_model_test.dart b/test/models/userscript_model_test.dart new file mode 100644 index 00000000..5a72f6fa --- /dev/null +++ b/test/models/userscript_model_test.dart @@ -0,0 +1,54 @@ +import 'package:test/test.dart'; +import 'package:torn_pda/models/userscript_model.dart'; + +void main() { + group('Userscript header parsing', () { + test('@grant none', () { + final headers = UserScriptModel.parseHeader(''' +// ==UserScript== +// @name Test Script +// @namespace http://tampermonkey.net/ +// @version 0.1 +// @description Test description +// @author You +// @match http://example.com/* +// @grant none +// ==/UserScript== +'''); + + expect(headers['grant'], []); + }); + + test('@grant GM.setValue/GM.getValue', () { + final headers = UserScriptModel.parseHeader(''' +// ==UserScript== +// @name Test Script +// @namespace http://tampermonkey.net/ +// @version 0.1 +// @description Test description +// @author You +// @match http://example.com/* +// @grant GM.setValue +// @grant GM.getValue +// ==/UserScript== +'''); + + expect(headers['grant'], ["GM.setValue", "GM.getValue"]); + }); + +test('@grant not included', () { + final headers = UserScriptModel.parseHeader(''' +// ==UserScript== +// @name Test Script +// @namespace http://tampermonkey.net/ +// @version 0.1 +// @description Test description +// @author You +// @match http://example.com/* +// ==/UserScript== +'''); + + expect(headers['grant'], []); + }); + }); +} diff --git a/test/providers/userscripts_apis_provider_test.dart b/test/providers/userscripts_apis_provider_test.dart new file mode 100644 index 00000000..5c6c84d4 --- /dev/null +++ b/test/providers/userscripts_apis_provider_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:torn_pda/providers/userscripts_apis_provider.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + var result = await UserscriptApisProvider.apis; + test('Returns a Map', () async { + expect(result, isA>()); + }); + + test('Is not empty', () async { + expect(result, isNotEmpty); + }); + + test('Contains GM_getValue.js', () async { + expect(result.keys, contains('GM_getValue')); + }); + + test('Contains default.js', () async { + expect(result.keys, contains('default')); + }); +} From 36ec283e3b95e7661b6817ec6b6776d2a0134f3e Mon Sep 17 00:00:00 2001 From: Travis Smith Date: Tue, 27 May 2025 20:46:02 -0400 Subject: [PATCH 2/4] Basic support using localStorage for get/setValue * Includes a test script now for the API surface; checked it passes in other script hosts * Figured out how to avoid leaking Future/async all the way the stack --- assets/userscripts/apis/GM.getValue.js | 3 +- assets/userscripts/apis/GM.setValue.js | 4 +- assets/userscripts/apis/GM_getValue.js | 2 +- assets/userscripts/apis/GM_setValue.js | 2 +- assets/userscripts/apis/default.js | 23 +++- lib/main.dart | 2 + lib/models/userscript_model.dart | 110 +++++++---------- lib/providers/userscripts_apis_provider.dart | 11 +- lib/providers/userscripts_provider.dart | 48 +++++--- .../userscripts_apis_provider_test.dart | 3 +- userscripts/Userscripts API Check.js | 116 ++++++++++++++++++ 11 files changed, 233 insertions(+), 91 deletions(-) create mode 100644 userscripts/Userscripts API Check.js diff --git a/assets/userscripts/apis/GM.getValue.js b/assets/userscripts/apis/GM.getValue.js index 080bd9bb..92d9253f 100644 --- a/assets/userscripts/apis/GM.getValue.js +++ b/assets/userscripts/apis/GM.getValue.js @@ -1,3 +1,4 @@ +// https://wiki.greasespot.net/GM.getValue GM.getValue = function(key, defaultValue) { - return localStorage.getItem(key) ?? defaultValue; + return Promise.resolve(localStorage.getItem(key) ?? defaultValue); } \ No newline at end of file diff --git a/assets/userscripts/apis/GM.setValue.js b/assets/userscripts/apis/GM.setValue.js index 64d15c1e..8f8acb76 100644 --- a/assets/userscripts/apis/GM.setValue.js +++ b/assets/userscripts/apis/GM.setValue.js @@ -1,3 +1,5 @@ +// https://wiki.greasespot.net/GM.setValue GM.setValue = function(key, value) { - localStorage.setItem(key, value); + localStorage.setItem(key, value) + return Promise.resolve(); } \ No newline at end of file diff --git a/assets/userscripts/apis/GM_getValue.js b/assets/userscripts/apis/GM_getValue.js index 12d6070d..2c6ff6d6 100644 --- a/assets/userscripts/apis/GM_getValue.js +++ b/assets/userscripts/apis/GM_getValue.js @@ -1,3 +1,3 @@ -var GM_getValue = function(key, defaultValue) { +let GM_getValue = function(key, defaultValue) { return localStorage.getItem(key) ?? defaultValue; } \ No newline at end of file diff --git a/assets/userscripts/apis/GM_setValue.js b/assets/userscripts/apis/GM_setValue.js index ca0f1b94..5c0a0bf2 100644 --- a/assets/userscripts/apis/GM_setValue.js +++ b/assets/userscripts/apis/GM_setValue.js @@ -1,3 +1,3 @@ -var GM_setValue = function(key, value) { +let GM_setValue = function(key, value) { localStorage.setItem(key, value); } \ No newline at end of file diff --git a/assets/userscripts/apis/default.js b/assets/userscripts/apis/default.js index 32237019..0f55c3dd 100644 --- a/assets/userscripts/apis/default.js +++ b/assets/userscripts/apis/default.js @@ -1 +1,22 @@ -var GM = {}; \ No newline at end of file +// > All scripts always get GM.info even without specifically requesting it. +// per https://wiki.greasespot.net/@grant +let GM = { + info: { + // https://wiki.greasespot.net/GM.info + scriptHandler: 'TornPDA', + scriptMetaStr: '', + scriptWillUpdate: false, + version: '0.0.0', + script: { + name: 'Default Script', + namespace: 'default', + description: 'A default userscript.', + excludes: [], + includes: [], + matches: [], + resources: {}, + 'run-at': "document-start", + version: '0.0.0' + } + } +}; diff --git a/lib/main.dart b/lib/main.dart index 17fae695..9ea58d62 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -55,6 +55,7 @@ import 'package:torn_pda/providers/theme_provider.dart'; import 'package:torn_pda/providers/trades_provider.dart'; import 'package:torn_pda/providers/user_controller.dart'; import 'package:torn_pda/providers/user_details_provider.dart'; +import 'package:torn_pda/providers/userscripts_apis_provider.dart'; import 'package:torn_pda/providers/userscripts_provider.dart'; import 'package:torn_pda/providers/war_controller.dart'; import 'package:torn_pda/providers/webview_provider.dart'; @@ -158,6 +159,7 @@ Future main() async { // START ## Force splash screen to stay on until we get essential start-up data FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); await _shouldSyncDeviceTheme(widgetsBinding); + await UserscriptApisProvider.initialize(); FlutterNativeSplash.remove(); // END ## Release splash screen diff --git a/lib/models/userscript_model.dart b/lib/models/userscript_model.dart index 292e958d..e42a12c0 100644 --- a/lib/models/userscript_model.dart +++ b/lib/models/userscript_model.dart @@ -6,11 +6,9 @@ import 'dart:convert'; import "package:http/http.dart" as http; -UserScriptModel userScriptModelFromJson(String str) => - UserScriptModel.fromJson(json.decode(str)); +UserScriptModel userScriptModelFromJson(String str) => UserScriptModel.fromJson(json.decode(str)); -String userScriptModelToJson(UserScriptModel data) => - json.encode(data.toJson()); +String userScriptModelToJson(UserScriptModel data) => json.encode(data.toJson()); enum UserScriptTime { start, end } @@ -37,8 +35,7 @@ class UserScriptModel { required this.isExample, this.customApiKey = "", this.customApiKeyCandidate = false, - this.grants = const [] - }); + this.grants = const []}); bool enabled; List matches; @@ -59,58 +56,45 @@ class UserScriptModel { if (json["exampleCode"] is int) { final bool enabled = json["enabled"] is bool ? json["enabled"] : true; final String source = json["source"] is String ? json["source"] : ""; - final List matches = json["urls"] is List - ? json["urls"].cast() - : tryGetMatches(source); + final List matches = json["urls"] is List ? json["urls"].cast() : tryGetMatches(source); final String name = json["name"] is String ? json["name"] : "Unknown"; - final String version = json["version"] is String - ? json["version"] - : tryGetVersion(source) ?? "0.0.0"; + final String version = json["version"] is String ? json["version"] : tryGetVersion(source) ?? "0.0.0"; final bool edited = json["edited"] is bool ? json["edited"] : false; - final UserScriptTime time = - json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end; - final bool isExample = - json["isExample"] ?? (json["exampleCode"] ?? 0) > 0; + final UserScriptTime time = json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end; + final bool isExample = json["isExample"] ?? (json["exampleCode"] ?? 0) > 0; final url = json["url"] is String ? json["url"] - : tryGetUrl(json["source"]) ?? - (isExample ? exampleScriptURLs[json["exampleCode"] - 1] : null); - final updateStatus = UserScriptUpdateStatus.values.byName( - json["updateStatus"] ?? (url is String ? "upToDate" : "noRemote")); + : tryGetUrl(json["source"]) ?? (isExample ? exampleScriptURLs[json["exampleCode"] - 1] : null); + final updateStatus = + UserScriptUpdateStatus.values.byName(json["updateStatus"] ?? (url is String ? "upToDate" : "noRemote")); return UserScriptModel( - enabled: enabled, - matches: matches, - name: name, - version: version, - edited: edited, - source: source, - time: time, - url: url, - updateStatus: updateStatus, - isExample: isExample, - grants: [] // Old model does not have grants - ); + enabled: enabled, + matches: matches, + name: name, + version: version, + edited: edited, + source: source, + time: time, + url: url, + updateStatus: updateStatus, + isExample: isExample, + grants: [] // Old model does not have grants + ); } else { return UserScriptModel( enabled: json["enabled"], - matches: json["matches"] is List - ? json["matches"].cast() - : const ["*"], + matches: json["matches"] is List ? json["matches"].cast() : const ["*"], name: json["name"], version: json["version"], edited: json["edited"], source: json["source"], - time: - json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end, + time: json["time"] == "start" ? UserScriptTime.start : UserScriptTime.end, url: json["url"], - updateStatus: UserScriptUpdateStatus.values - .byName(json["updateStatus"] ?? "noRemote"), + updateStatus: UserScriptUpdateStatus.values.byName(json["updateStatus"] ?? "noRemote"), isExample: json["isExample"] ?? (json["exampleCode"] ?? 0) > 0, customApiKey: json["customApiKey"] ?? "", customApiKeyCandidate: json["customApiKeyCandidate"] ?? false, - grants: json["grants"] is List - ? json["grants"].cast() - : const [], + grants: json["grants"] is List ? json["grants"].cast() : const [], ); } } @@ -138,10 +122,7 @@ class UserScriptModel { matches: metaMap["matches"] ?? ["*"], url: url ?? metaMap["downloadURL"], updateStatus: updateStatus, - time: time ?? - (metaMap["injectionTime"] == "document-start" - ? UserScriptTime.start - : UserScriptTime.end), + time: time ?? (metaMap["injectionTime"] == "document-start" ? UserScriptTime.start : UserScriptTime.end), isExample: isExample ?? false, customApiKey: customApiKey ?? "", customApiKeyCandidate: customApiKeyCandidate ?? false, @@ -149,8 +130,7 @@ class UserScriptModel { ); } - static Future<({bool success, String message, UserScriptModel? model})> - fromURL(String url, {bool? isExample}) async { + static Future<({bool success, String message, UserScriptModel? model})> fromURL(String url, {bool? isExample}) async { try { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { @@ -159,9 +139,7 @@ class UserScriptModel { success: true, message: "Success", model: UserScriptModel.fromMetaMap(metaMap, - url: url, - updateStatus: UserScriptUpdateStatus.upToDate, - isExample: isExample ?? false), + url: url, updateStatus: UserScriptUpdateStatus.upToDate, isExample: isExample ?? false), ); } else { return ( @@ -188,8 +166,7 @@ class UserScriptModel { final List version1List = version1.split("."); final List version2List = version2.split("."); for (int i = 0; i < version1List.length; i++) { - if (version2List.length <= i || - int.parse(version1List[i]) > int.parse(version2List[i])) { + if (version2List.length <= i || int.parse(version1List[i]) > int.parse(version2List[i])) { return true; } } @@ -214,16 +191,14 @@ class UserScriptModel { static Map parseHeader(String source) { // Thanks to [ViolentMonkey](https://github.com/violentmonkey/violentmonkey) for the following two regexes - String? meta = RegExp( - r"((?:^|\n)\s*\/\/\x20==UserScript==)([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$") - .stringMatch(source); + String? meta = + RegExp(r"((?:^|\n)\s*\/\/\x20==UserScript==)([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$").stringMatch(source); if (meta == null || meta.isEmpty) { throw Exception("No header found in userscript."); } - Iterable metaMatches = - RegExp(r"^(?:^|\n)\s*\/\/\x20(@\S+)(.*)$", multiLine: true) - .allMatches(meta); + Iterable metaMatches = RegExp(r"^(?:^|\n)\s*\/\/\x20(@\S+)(.*)$", multiLine: true).allMatches(meta); Map metaMap = {"@match": [], "@grant": []}; + bool foundNoneGrant = false; for (final match in metaMatches) { if (match.groupCount < 2) { continue; @@ -235,6 +210,8 @@ class UserScriptModel { metaMap["@match"].add(match.group(2)!.trim()); } else if (match.group(1)?.toLowerCase() == "@grant") { if (match.group(2)?.trim() == "none") { + // see note below (after the loop) on this behavior + foundNoneGrant = true; continue; } metaMap["@grant"].add(match.group(2)!.trim()); @@ -242,6 +219,14 @@ class UserScriptModel { metaMap[match.group(1)!.trim().toLowerCase()] = match.group(2)!.trim(); } } + + if (foundNoneGrant) { + // per https://wiki.greasespot.net/@grant + // > If you specify none and something else, none takes precedence. This can be confusing. + // > Check for a none to remove if you're adding APIs you intend to use. + metaMap["@grant"] = []; + } + return { "name": metaMap["@name"], "version": metaMap["@version"], @@ -258,8 +243,7 @@ class UserScriptModel { shouldInject(String url, [UserScriptTime? time]) => enabled && (this.time == time || time == null) && - matches.any( - (match) => (match == "*" || url.contains(match.replaceAll("*", "")))); + matches.any((match) => (match == "*" || url.contains(match.replaceAll("*", "")))); void update({ bool? enabled, @@ -292,9 +276,7 @@ class UserScriptModel { this.name = metaMap["name"]; } if (metaMap["injectionTime"] != null) { - this.time = metaMap["injectionTime"] == "document-start" - ? UserScriptTime.start - : UserScriptTime.end; + this.time = metaMap["injectionTime"] == "document-start" ? UserScriptTime.start : UserScriptTime.end; } if (metaMap["downloadURL"] != null) { this.url = metaMap["downloadURL"]; diff --git a/lib/providers/userscripts_apis_provider.dart b/lib/providers/userscripts_apis_provider.dart index d262e684..e42e99ec 100644 --- a/lib/providers/userscripts_apis_provider.dart +++ b/lib/providers/userscripts_apis_provider.dart @@ -3,9 +3,16 @@ import 'dart:convert'; import 'package:flutter/services.dart' show rootBundle; class UserscriptApisProvider { - static final Future> _apis = _getApisMap(_getApis()); + static Map _apis = {}; - static Future> get apis async { + /// Initializes the provider by loading the API scripts from the assets. + /// Returns a [Future] if someone wants to check for errors. + static Future> initialize() async { + _apis = await _getApisMap(_getApis()); + return _apis; + } + + static Map get apis { return _apis; } diff --git a/lib/providers/userscripts_provider.dart b/lib/providers/userscripts_provider.dart index 95fcefe8..220a1a6a 100644 --- a/lib/providers/userscripts_provider.dart +++ b/lib/providers/userscripts_provider.dart @@ -99,22 +99,32 @@ class UserScriptsProvider extends ChangeNotifier { return UnmodifiableListView(const []); } - return UnmodifiableListView( - _userScriptList.where((s) => s.shouldInject(url, time)) - .map((s) { - return UserScript( - groupName: s.name, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - // If the script is a custom API key script, we need to replace the API key - source: await adaptSource( - source: s.source, - scriptFinalApiKey: s.customApiKey.isNotEmpty ? s.customApiKey : pdaApiKey, - grants: s.grants, - ), - ); - }, - ), - ); + try { + return UnmodifiableListView( + _userScriptList.where((s) => s.shouldInject(url, time)) + .map((s) { + return UserScript( + groupName: s.name, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + // If the script is a custom API key script, we need to replace the API key + source: adaptSource( + source: s.source, + scriptFinalApiKey: s.customApiKey.isNotEmpty ? s.customApiKey : pdaApiKey, + grants: s.grants, + ), + ); + }, + ), + ); + } catch (e, trace) { + if (!Platform.isWindows) { + FirebaseCrashlytics.instance.log("PDA error at userscripts getCondSources. Error: $e"); + FirebaseCrashlytics.instance.recordError(e, trace); + } + logToUser("PDA error at userscripts getCondSources. Error: $e"); + } + + return UnmodifiableListView(const []); } List getScriptsToRemove({ @@ -130,13 +140,13 @@ class UserScriptsProvider extends ChangeNotifier { } } - Future adaptSource({ + String adaptSource({ required String source, required String scriptFinalApiKey, required List? grants - }) async { + }) { final String withApiKey = source.replaceAll("###PDA-APIKEY###", scriptFinalApiKey); - var apis = await UserscriptApisProvider.apis; + var apis = UserscriptApisProvider.apis; // default includes the GM object and other stuff that always appears String grantInjections = apis["default"] ?? ""; diff --git a/test/providers/userscripts_apis_provider_test.dart b/test/providers/userscripts_apis_provider_test.dart index 5c6c84d4..866463cf 100644 --- a/test/providers/userscripts_apis_provider_test.dart +++ b/test/providers/userscripts_apis_provider_test.dart @@ -4,7 +4,8 @@ import 'package:torn_pda/providers/userscripts_apis_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - var result = await UserscriptApisProvider.apis; + await UserscriptApisProvider.initialize(); + var result = UserscriptApisProvider.apis; test('Returns a Map', () async { expect(result, isA>()); }); diff --git a/userscripts/Userscripts API Check.js b/userscripts/Userscripts API Check.js new file mode 100644 index 00000000..f7216c02 --- /dev/null +++ b/userscripts/Userscripts API Check.js @@ -0,0 +1,116 @@ +// ==UserScript== +// @name Userscripts API Check +// @namespace https://github.com/TravisTheTechie +// @version 1.0 +// @description Attempts to validate a userscript's API compatibility with spec. +// @author Travis Smith +// @license MIT +// @match https://* +// @grant GM.getValue +// @grant GM.setValue +// ==/UserScript== + +// clear out the document body +document.body.innerHTML = ''; + +const checks = [ + { + name: "GM", + checker: () => { + if (typeof GM !== 'object') { + return Promise.reject("GM is not an object."); + } + return Promise.resolve(true); + } + }, + { + name: "GM.info", + checker: () => { + if (typeof GM.info !== 'object') { + return Promise.reject("GM.info is not an object."); + } + return Promise.resolve(true); + } + }, + { + name: "GM.getValue/GM.setValue", + checker: () => { + if (typeof GM.getValue !== 'function') { + return Promise.reject("GM.getValue is not a function."); + } + if (typeof GM.setValue !== 'function') { + return Promise.reject("GM.setValue is not a function."); + } + try { + return GM.setValue("testKey", "testValue") + .then(() => GM.getValue("testKey", "defaultValue")) + .then(value => value === "testValue" ? true : Promise.reject("GM.getValue did not return the expected value.")) + .then(() => GM.getValue("testKey-doesn't-exist", "defaultValue")) + .then(value => value === "defaultValue" ? true : Promise.reject("GM.getValue did not return the default value for a non-existent key.")); + } catch (error) { + return Promise.reject(`GM.getValue threw an error: ${error}`); + } + } + }, + { + name: "GM.deleteValue", + checker: () => { + if (typeof GM.deleteValue !== 'function') { + return Promise.reject("GM.deleteValue is not a function."); + } + try { + return GM.setValue("testKey2", "testValue") + .then(() => GM.deleteValue("testKey2")) + .then(() => GM.getValue("testKey2", "defaultValue")) + .then(value => value === "defaultValue" ? true : Promise.reject("GM.deleteValue did not remove the key.")); + } catch (error) { + return Promise.reject(`GM.deleteValue threw an error: ${error}`); + } + } + }, + { + name: "GM.listValues", + checker: () => { + if (typeof GM.listValues !== 'function') { + return Promise.reject("GM.listValues is not a function."); + } + try { + return GM.setValue("ListTestKey", "testValue") + .then(() => GM.setValue("ListTestKey2", "testValue2")) + .then (() => GM.listValues()) + .then(values => { + if (!Array.isArray(values)) { + return Promise.reject("GM.listValues did not return an array."); + } + // Check if the array contains the test key + if (!values.includes("ListTestKey") && !values.includes("ListTestKey2")) { + return Promise.reject("GM.listValues did not include expected keys."); + } + return true; + }); + } catch (error) { + return Promise.reject(`GM.listValues threw an error: ${error}`); + } + } + } +].map(check => ({ + ...check, + checkPromise: check.checker() +})); + +checks.forEach(check => { + check.checkPromise + .then(() => { + const resultElement = document.createElement('div'); + resultElement.className = "result pass"; + resultElement.textContent = `${check.name}: Passed`; + document.body.appendChild(resultElement); + }) + .catch(error => { + console.log(`Check failed for ${check.name}:`, error); + const resultElement = document.createElement('div'); + resultElement.className = "result fail"; + resultElement.textContent = `${check.name}: Failed - ${error}`; + document.body.appendChild(resultElement); + }); +}); \ No newline at end of file From 7add6e2ef3f4ffa128eb051cc71e9ef4a38f42c1 Mon Sep 17 00:00:00 2001 From: Travis Smith Date: Tue, 27 May 2025 21:02:58 -0400 Subject: [PATCH 3/4] Updating formatting. --- lib/models/userscript_model.dart | 73 ++++++++++---------- lib/providers/userscripts_provider.dart | 88 +++++++++---------------- 2 files changed, 69 insertions(+), 92 deletions(-) diff --git a/lib/models/userscript_model.dart b/lib/models/userscript_model.dart index e42a12c0..521b72f7 100644 --- a/lib/models/userscript_model.dart +++ b/lib/models/userscript_model.dart @@ -22,20 +22,21 @@ enum UserScriptUpdateStatus { } class UserScriptModel { - UserScriptModel( - {this.enabled = true, - this.matches = const ["*"], - required this.name, - this.version = "0.0.0", - this.edited = false, - required this.source, - this.time = UserScriptTime.end, - this.url, - this.updateStatus = UserScriptUpdateStatus.noRemote, - required this.isExample, - this.customApiKey = "", - this.customApiKeyCandidate = false, - this.grants = const []}); + UserScriptModel({ + this.enabled = true, + this.matches = const ["*"], + required this.name, + this.version = "0.0.0", + this.edited = false, + required this.source, + this.time = UserScriptTime.end, + this.url, + this.updateStatus = UserScriptUpdateStatus.noRemote, + required this.isExample, + this.customApiKey = "", + this.customApiKeyCandidate = false, + this.grants = const [], + }); bool enabled; List matches; @@ -68,18 +69,18 @@ class UserScriptModel { final updateStatus = UserScriptUpdateStatus.values.byName(json["updateStatus"] ?? (url is String ? "upToDate" : "noRemote")); return UserScriptModel( - enabled: enabled, - matches: matches, - name: name, - version: version, - edited: edited, - source: source, - time: time, - url: url, - updateStatus: updateStatus, - isExample: isExample, - grants: [] // Old model does not have grants - ); + enabled: enabled, + matches: matches, + name: name, + version: version, + edited: edited, + source: source, + time: time, + url: url, + updateStatus: updateStatus, + isExample: isExample, + grants: [], // Old model does not have grants + ); } else { return UserScriptModel( enabled: json["enabled"], @@ -99,15 +100,17 @@ class UserScriptModel { } } - factory UserScriptModel.fromMetaMap(Map metaMap, - {String? url, - UserScriptUpdateStatus updateStatus = UserScriptUpdateStatus.noRemote, - bool? isExample, - String? name, - String? source, - UserScriptTime? time, - String? customApiKey, - bool? customApiKeyCandidate}) { + factory UserScriptModel.fromMetaMap( + Map metaMap, { + String? url, + UserScriptUpdateStatus updateStatus = UserScriptUpdateStatus.noRemote, + bool? isExample, + String? name, + String? source, + UserScriptTime? time, + String? customApiKey, + bool? customApiKeyCandidate, + }) { if (metaMap["name"] == null) { throw Exception("No script name found in userscript"); } diff --git a/lib/providers/userscripts_provider.dart b/lib/providers/userscripts_provider.dart index 220a1a6a..ecf49884 100644 --- a/lib/providers/userscripts_provider.dart +++ b/lib/providers/userscripts_provider.dart @@ -101,8 +101,8 @@ class UserScriptsProvider extends ChangeNotifier { try { return UnmodifiableListView( - _userScriptList.where((s) => s.shouldInject(url, time)) - .map((s) { + _userScriptList.where((s) => s.shouldInject(url, time)).map( + (s) { return UserScript( groupName: s.name, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, @@ -133,24 +133,17 @@ class UserScriptsProvider extends ChangeNotifier { if (!_userScriptsEnabled) { return const []; } else { - return _userScriptList - .where((s) => !s.shouldInject(url)) - .map((s) => s.name) - .toList(); + return _userScriptList.where((s) => !s.shouldInject(url)).map((s) => s.name).toList(); } } - String adaptSource({ - required String source, - required String scriptFinalApiKey, - required List? grants - }) { + String adaptSource({required String source, required String scriptFinalApiKey, required List? grants}) { final String withApiKey = source.replaceAll("###PDA-APIKEY###", scriptFinalApiKey); var apis = UserscriptApisProvider.apis; // default includes the GM object and other stuff that always appears String grantInjections = apis["default"] ?? ""; - + // each item that can be granted is checked and injected for (final api in apis.entries) { if (grants?.contains(api.key) ?? false) { @@ -163,16 +156,11 @@ class UserScriptsProvider extends ChangeNotifier { return anonFunction; } - Future<({bool success, String? message})> addUserScriptFromURL(String url, - {bool? isExample}) async { + Future<({bool success, String? message})> addUserScriptFromURL(String url, {bool? isExample}) async { final response = await UserScriptModel.fromURL(url, isExample: isExample); if (response.success && response.model != null) { - if (_userScriptList - .any((script) => script.name == response.model!.name)) { - return ( - success: false, - message: "Script with same name already exists" - ); + if (_userScriptList.any((script) => script.name == response.model!.name)) { + return (success: false, message: "Script with same name already exists"); } userScriptList.add(response.model!); _sort(); @@ -246,19 +234,17 @@ class UserScriptsProvider extends ChangeNotifier { bool couldParseHeader = true; List? matches = UserScriptModel.tryGetMatches(source); List? grants = UserScriptModel.tryGetGrants(source); - userScriptList - .firstWhere((script) => script.name == editedModel.name) - .update( - name: name, - time: time, - source: source, - matches: matches, - grants: grants, - updateStatus: isFromRemote - ? UserScriptUpdateStatus.upToDate - : editedModel.updateStatus == UserScriptUpdateStatus.noRemote - ? UserScriptUpdateStatus.noRemote - : UserScriptUpdateStatus.localModified); + userScriptList.firstWhere((script) => script.name == editedModel.name).update( + name: name, + time: time, + source: source, + matches: matches, + grants: grants, + updateStatus: isFromRemote + ? UserScriptUpdateStatus.upToDate + : editedModel.updateStatus == UserScriptUpdateStatus.noRemote + ? UserScriptUpdateStatus.noRemote + : UserScriptUpdateStatus.localModified); notifyListeners(); _saveUserScriptsListSharedPrefs(); return couldParseHeader; @@ -337,13 +323,10 @@ class UserScriptsProvider extends ChangeNotifier { ); } catch (e, trace) { if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log( - "PDA error at adding server userscript. Error: $e. Stack: $trace"); - } - if (!Platform.isWindows) + FirebaseCrashlytics.instance.log("PDA error at adding server userscript. Error: $e. Stack: $trace"); FirebaseCrashlytics.instance.recordError(e, trace); - logToUser( - "PDA error at adding server userscript. Error: $e. Stack: $trace"); + } + logToUser("PDA error at adding server userscript. Error: $e. Stack: $trace"); } } _sort(); @@ -357,8 +340,7 @@ class UserScriptsProvider extends ChangeNotifier { } void _sort() { - _userScriptList - .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + _userScriptList.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } void changeScriptsFirstTime(bool value) { @@ -388,8 +370,7 @@ class UserScriptsProvider extends ChangeNotifier { // Check if the script with the same name already exists in the list // (user-reported bug) final String name = decodedModel.name.toLowerCase(); - if (_userScriptList - .any((script) => script.name.toLowerCase() == name)) continue; + if (_userScriptList.any((script) => script.name.toLowerCase() == name)) continue; bool customApiKeyCandidate = false; if (decodedModel.source.contains("###PDA-APIKEY###")) { @@ -412,13 +393,10 @@ class UserScriptsProvider extends ChangeNotifier { ); } catch (e, trace) { if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log( - "PDA error at adding one userscript. Error: $e. Stack: $trace"); - } - if (!Platform.isWindows) + FirebaseCrashlytics.instance.log("PDA error at adding one userscript. Error: $e. Stack: $trace"); FirebaseCrashlytics.instance.recordError(e, trace); - logToUser( - "PDA error at adding one userscript. Error: $e. Stack: $trace"); + } + logToUser("PDA error at adding one userscript. Error: $e. Stack: $trace"); } } } @@ -431,11 +409,9 @@ class UserScriptsProvider extends ChangeNotifier { } catch (e, trace) { // Pass (scripts will be empty) if (!Platform.isWindows) { - FirebaseCrashlytics.instance.log( - "PDA error at userscripts first load. Error: $e. Stack: $trace"); - } - if (!Platform.isWindows) + FirebaseCrashlytics.instance.log("PDA error at userscripts first load. Error: $e. Stack: $trace"); FirebaseCrashlytics.instance.recordError(e, trace); + } logToUser("PDA error at userscript first load. Error: $e. Stack: $trace"); } } @@ -444,8 +420,7 @@ class UserScriptsProvider extends ChangeNotifier { int updates = 0; await Future.wait(_userScriptList.map((s) { // Only check for updates on relevant scripts - if (s.updateStatus == UserScriptUpdateStatus.localModified || - s.updateStatus == UserScriptUpdateStatus.noRemote) { + if (s.updateStatus == UserScriptUpdateStatus.localModified || s.updateStatus == UserScriptUpdateStatus.noRemote) { return Future.value(); } // Ensure script has a valid URL @@ -475,8 +450,7 @@ class UserScriptsProvider extends ChangeNotifier { userScriptList.removeWhere((s) => s.isExample); await Future.wait(defaultScriptUrls.map((url) => url == null ? Future.value() - : addUserScriptFromURL(url, isExample: true) - .then((r) => r.success ? added++ : failed++))); + : addUserScriptFromURL(url, isExample: true).then((r) => r.success ? added++ : failed++))); return ( added: added, failed: failed, From bfff84e2c6b4fd07f6b03f58d67d323f9f7e49c0 Mon Sep 17 00:00:00 2001 From: Travis Smith Date: Tue, 27 May 2025 21:05:40 -0400 Subject: [PATCH 4/4] Missed something during rebase. --- lib/providers/userscripts_provider.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/providers/userscripts_provider.dart b/lib/providers/userscripts_provider.dart index ecf49884..04e0efae 100644 --- a/lib/providers/userscripts_provider.dart +++ b/lib/providers/userscripts_provider.dart @@ -238,6 +238,8 @@ class UserScriptsProvider extends ChangeNotifier { name: name, time: time, source: source, + customApiKey: customApiKey ?? "", + customApiKeyCandidate: customApiKeyCandidate ?? false, matches: matches, grants: grants, updateStatus: isFromRemote