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..92d9253f --- /dev/null +++ b/assets/userscripts/apis/GM.getValue.js @@ -0,0 +1,4 @@ +// https://wiki.greasespot.net/GM.getValue +GM.getValue = function(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 new file mode 100644 index 00000000..8f8acb76 --- /dev/null +++ b/assets/userscripts/apis/GM.setValue.js @@ -0,0 +1,5 @@ +// https://wiki.greasespot.net/GM.setValue +GM.setValue = function(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 new file mode 100644 index 00000000..2c6ff6d6 --- /dev/null +++ b/assets/userscripts/apis/GM_getValue.js @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..5c0a0bf2 --- /dev/null +++ b/assets/userscripts/apis/GM_setValue.js @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..0f55c3dd --- /dev/null +++ b/assets/userscripts/apis/default.js @@ -0,0 +1,22 @@ +// > 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 6d97b57e..521b72f7 100644 --- a/lib/models/userscript_model.dart +++ b/lib/models/userscript_model.dart @@ -35,6 +35,7 @@ class UserScriptModel { required this.isExample, this.customApiKey = "", this.customApiKeyCandidate = false, + this.grants = const [], }); bool enabled; @@ -49,6 +50,7 @@ class UserScriptModel { bool isExample; String customApiKey; bool customApiKeyCandidate; + List grants; factory UserScriptModel.fromJson(Map json) { // First check if is old model @@ -77,6 +79,7 @@ class UserScriptModel { url: url, updateStatus: updateStatus, isExample: isExample, + grants: [], // Old model does not have grants ); } else { return UserScriptModel( @@ -92,19 +95,22 @@ class UserScriptModel { isExample: json["isExample"] ?? (json["exampleCode"] ?? 0) > 0, customApiKey: json["customApiKey"] ?? "", customApiKeyCandidate: json["customApiKeyCandidate"] ?? false, + grants: json["grants"] is List ? json["grants"].cast() : const [], ); } } - 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"); } @@ -123,6 +129,7 @@ class UserScriptModel { isExample: isExample ?? false, customApiKey: customApiKey ?? "", customApiKeyCandidate: customApiKeyCandidate ?? false, + grants: metaMap["grant"] ?? [], ); } @@ -179,6 +186,7 @@ class UserScriptModel { "url": url, "updateStatus": updateStatus.name, "isExample": isExample, + "grants": grants, "time": time == UserScriptTime.start ? "start" : "end", "customApiKey": customApiKey, "customApiKeyCandidate": customApiKeyCandidate, @@ -192,7 +200,8 @@ class UserScriptModel { throw Exception("No header found in userscript."); } Iterable metaMatches = RegExp(r"^(?:^|\n)\s*\/\/\x20(@\S+)(.*)$", multiLine: true).allMatches(meta); - Map metaMap = {"@match": []}; + Map metaMap = {"@match": [], "@grant": []}; + bool foundNoneGrant = false; for (final match in metaMatches) { if (match.groupCount < 2) { continue; @@ -202,10 +211,25 @@ 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") { + // see note below (after the loop) on this behavior + foundNoneGrant = true; + continue; + } + metaMap["@grant"].add(match.group(2)!.trim()); } else { 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"], @@ -214,6 +238,7 @@ class UserScriptModel { "injectionTime": metaMap["@run-at"] ?? "document-end", "downloadURL": metaMap["@downloadurl"], "updateURL": metaMap["@updateurl"], + "grant": metaMap["@grant"], "source": source, }; } @@ -234,6 +259,7 @@ class UserScriptModel { String? url, String? customApiKey, bool? customApiKeyCandidate, + List? grants, required UserScriptUpdateStatus updateStatus, }) { if (source != null) { @@ -246,6 +272,9 @@ 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"]; } @@ -319,6 +348,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..e42e99ec --- /dev/null +++ b/lib/providers/userscripts_apis_provider.dart @@ -0,0 +1,45 @@ +import 'dart:async' show Future; +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; + +class UserscriptApisProvider { + static Map _apis = {}; + + /// 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; + } + + 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..04e0efae 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,31 +95,35 @@ 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 []); + } + + 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 []); } @@ -132,9 +137,20 @@ class UserScriptsProvider extends ChangeNotifier { } } - String adaptSource({required String source, required String scriptFinalApiKey}) { + String adaptSource({required String source, required String scriptFinalApiKey, required List? grants}) { final String withApiKey = source.replaceAll("###PDA-APIKEY###", scriptFinalApiKey); - String anonFunction = "(function() {$withApiKey}());"; + 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) { + grantInjections += api.value; + } + } + String anonFunction = "(function() {$grantInjections;$withApiKey}());"; anonFunction = anonFunction.replaceAll('“', '"'); anonFunction = anonFunction.replaceAll('”', '"'); return anonFunction; @@ -177,6 +193,7 @@ class UserScriptsProvider extends ChangeNotifier { List? matches, String? customApiKey, bool? customApiKeyCandidate, + List? grants, }) { final newScript = UserScriptModel( name: name, @@ -191,6 +208,7 @@ class UserScriptsProvider extends ChangeNotifier { isExample: isExample, customApiKey: customApiKey ?? "", customApiKeyCandidate: customApiKeyCandidate ?? false, + grants: grants ?? const [], ); userScriptList.add(newScript); @@ -213,13 +231,9 @@ class UserScriptsProvider extends ChangeNotifier { String? customApiKey, bool? customApiKeyCandidate, ) { - List? matches; bool couldParseHeader = true; - try { - matches = UserScriptModel.tryGetMatches(source); - } catch (e) { - matches ??= const ["*"]; - } + List? matches = UserScriptModel.tryGetMatches(source); + List? grants = UserScriptModel.tryGetGrants(source); userScriptList.firstWhere((script) => script.name == editedModel.name).update( name: name, time: time, @@ -227,6 +241,7 @@ class UserScriptsProvider extends ChangeNotifier { customApiKey: customApiKey ?? "", customApiKeyCandidate: customApiKeyCandidate ?? false, matches: matches, + grants: grants, updateStatus: isFromRemote ? UserScriptUpdateStatus.upToDate : editedModel.updateStatus == UserScriptUpdateStatus.noRemote @@ -306,12 +321,13 @@ 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.recordError(e, trace); } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); logToUser("PDA error at adding server userscript. Error: $e. Stack: $trace"); } } @@ -380,8 +396,8 @@ 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.recordError(e, trace); } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); logToUser("PDA error at adding one userscript. Error: $e. Stack: $trace"); } } @@ -396,8 +412,8 @@ class UserScriptsProvider extends ChangeNotifier { // Pass (scripts will be empty) if (!Platform.isWindows) { FirebaseCrashlytics.instance.log("PDA error at userscripts first load. Error: $e. Stack: $trace"); + FirebaseCrashlytics.instance.recordError(e, trace); } - if (!Platform.isWindows) FirebaseCrashlytics.instance.recordError(e, trace); logToUser("PDA error at userscript first load. Error: $e. Stack: $trace"); } } 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..866463cf --- /dev/null +++ b/test/providers/userscripts_apis_provider_test.dart @@ -0,0 +1,24 @@ +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(); + await UserscriptApisProvider.initialize(); + var result = 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')); + }); +} 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