-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 403de54
abduznik edited this page May 23, 2026
·
1 revision
Commit: 403de54fed0d1fb9f1be51cf243bfe1c63668f05
Author: abduznik
Date: 2026-03-27
-
feat: API key authentication support for RomM 4.8+, legacy username/password fallback
-
fix: persist search bar text when switching tabs
-
security: store sensitive credentials in Windows Credential Manager via flutter_secure_storage
-
fix: mask API key field, authHeader uses API key first for downloads
Why: Adds a new feature or capability to the application.
lib/core/romm/romm_models.dart | 4 ++
lib/core/romm/romm_service.dart | 13 ++++++
lib/providers/romm_provider.dart | 9 +++--
lib/ui/screens/library_screen.dart | 14 +++++++
lib/ui/screens/settings_screen.dart | 55 +++++++++++++++++++++----
linux/flutter/generated_plugin_registrant.cc | 4 ++
linux/flutter/generated_plugins.cmake | 1 +
macos/Flutter/GeneratedPluginRegistrant.swift | 2 +
pubspec.lock | 56 ++++++++++++++++++++++++++
pubspec.yaml | 1 +
windows/flutter/generated_plugin_registrant.cc | 3 ++
windows/flutter/generated_plugins.cmake | 1 +
12 files changed, 153 insertions(+), 10 deletions(-)
lib/core/romm/romm_models.dartlib/core/romm/romm_service.dartlib/providers/romm_provider.dartlib/ui/screens/library_screen.dartlib/ui/screens/settings_screen.dartlinux/flutter/generated_plugin_registrant.cclinux/flutter/generated_plugins.cmakemacos/Flutter/GeneratedPluginRegistrant.swiftpubspec.lockpubspec.yamlwindows/flutter/generated_plugin_registrant.ccwindows/flutter/generated_plugins.cmake
diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart
index afca4b0..b2382c9 100644
--- a/lib/core/romm/romm_models.dart
+++ b/lib/core/romm/romm_models.dart
@@ -119,12 +119,14 @@ class RomMConfig {
final String username;
final String password;
final String? token;
+ final String apiKey; // Added apiKey field
RomMConfig({
required this.baseUrl,
required this.username,
required this.password,
this.token,
+ this.apiKey = '', // Added apiKey to constructor with default
});
factory RomMConfig.fromJson(Map<String, dynamic> json) {
@@ -133,6 +135,7 @@ class RomMConfig {
username: json['username']?.toString() ?? '',
password: json['password']?.toString() ?? '',
token: json['token']?.toString(),
+ apiKey: json['apiKey']?.toString() ?? '', // Added apiKey from JSON with default
);
}
@@ -142,6 +145,7 @@ class RomMConfig {
'username': username,
'password': password,
if (token != null) 'token': token,
+ 'apiKey': apiKey, // Added apiKey to toJson
};
}
}
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index aae4062..bd6be2a 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -21,6 +21,12 @@ class RommService {
// If the server rejects the Bearer token with 403, retry once with Basic auth.
_dio.interceptors.add(InterceptorsWrapper(
onError: (DioException e, ErrorInterceptorHandler handler) async {
+ // Check for 401 with API Key
+ if (e.response?.statusCode == 401 && config.apiKey.isNotEmpty) {
+ throw Exception('Invalid API key. Please check your token in RomM Settings → Client API Tokens.');
+ }
+
+ // If the server rejects the Bearer token with 403, retry once with Basic auth.
if (e.response?.statusCode == 403 &&
e.requestOptions.extra['_basicRetry'] != true &&
e.requestOptions.data is! FormData &&
@@ -44,10 +50,16 @@ class RommService {
/// Returns the appropriate auth Options for each request.
/// Uses Bearer token if available, falls back to Basic auth.
Options get _authOptions {
+ // Check for API Key first
+ if (config.apiKey.isNotEmpty) {
+ return Options(headers: {'Authorization': 'Bearer ${config.apiKey}'});
+ }
+ // Fallback to existing token logic
final token = config.token;
if (token != null && token.isNotEmpty) {
return Options(headers: {'Authorization': 'Bearer $token'});
}
+ // Fallback to Basic auth
final basic = 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}';
return Options(headers: {'Authorization': basic});
}
@@ -193,6 +205,7 @@ class RommService {
/// Returns the Authorization header value for downloads (Bearer if available, else Basic).
String get authHeader {
+ if (config.apiKey.isNotEmpty) return 'Bearer ${config.apiKey}';
final token = config.token;
if (token != null && token.isNotEmpty) return 'Bearer $token';
return 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}';
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 3924d3a..686b6d7 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:freegosy/core/storage/directory_service.dart';
import 'package:freegosy/core/romm/romm_models.dart';
import 'package:freegosy/core/romm/romm_service.dart';
@@ -7,16 +8,18 @@ import 'package:freegosy/core/emulator/strategy_registry.dart';
import 'package:freegosy/core/save/save_sync_service.dart';
import 'package:freegosy/core/emulator/strategies/windows_strategy.dart';
+final _secureStorage = const FlutterSecureStorage();
// Provider for loading RomMConfig (including stored Bearer token)
final rommConfigProvider = FutureProvider<RomMConfig>((ref) async {
final prefs = await SharedPreferences.getInstance();
final baseUrl = prefs.getString('rommBaseUrl') ?? 'https://api.romm.example.com';
final username = prefs.getString('rommUsername') ?? 'guest';
- final password = prefs.getString('rommPassword') ?? '';
- final token = prefs.getString('rommAuthToken');
+ final password = await _secureStorage.read(key: 'rommPassword') ?? '';
+ final token = await _secureStorage.read(key: 'rommAuthToken');
+ final apiKey = await _secureStorage.read(key: 'rommApiKey') ?? '';
- return RomMConfig(baseUrl: baseUrl, username: username, password: password, token: token);
+ return RomMConfig(baseUrl: baseUrl, username: username, password: password, token: token, apiKey: apiKey);
});
// Exposes a login function that fetches a Bearer token and refreshes the config/service providers.
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index cc7ebe7..7ec6261 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -27,6 +27,19 @@ class LibraryScreen extends ConsumerStatefulWidget {
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
Map<String, bool> _downloadedStates = {};
bool _downloadStatesLoaded = false;
+ late TextEditingController _searchController;
+
+ @override
+ void initState() {
+ super.initState();
+ _searchController = TextEditingController(text: ref.read(searchQueryProvider));
+ }
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
Future<void> _loadDownloadStates(
DirectoryService dirService, List<Game> games) async {
@@ -406,6 +419,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: TextField(
+ controller: _searchController,
decoration: InputDecoration(
hintText: 'Search games...',
prefixIcon: const Icon(Icons.search),
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index 74fddff..f75081b 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/storage/directory_service.dart';
import '../../core/emulator/emulator_registry_data.dart';
// import '../../core/extraction/extraction_service.dart'; // Removed unused import
import '../../providers/romm_provider.dart';
import '../../providers/library_provider.dart'; // Assuming this file contains the display providers and kDisplayPresets
import '../../core/romm/romm_service.dart';
+import '../../core/romm/romm_models.dart';
import 'settings_emulators_section.dart';
import 'settings_display_section.dart';
@@ -22,6 +24,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _baseUrlController;
late TextEditingController _usernameController;
late TextEditingController _passwordController;
+ late TextEditingController _apiKeyController; // Added API Key controller
bool _isSaving = false;
Map<String, bool> _emulatorInstallStates = {};
bool _emulatorsLoaded = false; // This state is managed here
@@ -33,6 +36,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_baseUrlController = TextEditingController();
_usernameController = TextEditingController();
_passwordController = TextEditingController();
+ _apiKeyController = TextEditingController(); // Initialize API Key controller
// Mark preferences as loaded (they are now awaited by the provider)
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -48,6 +52,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_baseUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
+ _apiKeyController.dispose(); // Dispose API Key controller
super.dispose();
}
@@ -101,6 +106,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_baseUrlController.text = rommConfig.baseUrl;
_usernameController.text = rommConfig.username;
_passwordController.text = rommConfig.password;
+ _apiKeyController.text = rommConfig.apiKey; // Load API Key into controller
return directoryServiceAsync.when(
data: (directoryService) {
@@ -118,7 +124,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
- _buildRommServerSection(context, ref, rommService),
+ _buildRommServerSection(context, ref, rommService, rommConfig),
const SizedBox(height: 24),
// Call the extracted display section function
buildDisplaySection(
@@ -168,7 +174,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}
// --- RomM Server Section ---
- Widget _buildRommServerSection(BuildContext context, WidgetRef ref, rommService) {
+ Widget _buildRommServerSection(BuildContext context, WidgetRef ref, RommService? rommService, RomMConfig rommConfig) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -200,6 +206,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
obscureText: true,
),
+ const SizedBox(height: 12),
+ TextField(
+ controller: _apiKeyController,
+ decoration: const InputDecoration(
+ labelText: 'API Key (RomM 4.8+)',
+ hintText: 'rmm_...',
+ border: OutlineInputBorder(),
+ helperText: 'Recommended. Generate in RomM Settings → Client API Tokens',
+ helperMaxLines: 2,
+ ),
+ keyboardType: TextInputType.text,
+ obscureText: true,
+ ),
+ const SizedBox(height: 16),
+ const Divider(), // Add divider
+ const SizedBox(height: 8),
+ const Text('Legacy Authentication (RomM 4.7 and below)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), // Add label
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -217,9 +240,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
try {
// Test connection by fetching platforms
final platforms = await rommService.getPlatforms();
+ String message;
+ if (rommConfig.apiKey.isNotEmpty) {
+ message = 'Connected via API key. ${platforms.length} platforms found.';
+ } else {
+ message = 'Connected via username/password. ${platforms.length} platforms found.';
+ }
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Connection successful! ${platforms.length} platforms found.'), backgroundColor: Colors.green),
+ SnackBar(content: Text(message), backgroundColor: Colors.green),
);
}
} catch (e) {
@@ -241,22 +270,34 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final baseUrl = _baseUrlController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text;
+ final apiKey = _apiKeyController.text.trim(); // Get API Key value
+
+ final secureStorage = const FlutterSecureStorage();
// Try Bearer token (OAuth2). If the server doesn't support
// it over HTTP or at all, fall back to Basic auth silently.
try {
await RommService.fetchToken(baseUrl, username, password);
+ // If successful, fetch token from preferences and move to secure storage
+ final prefs = await SharedPreferences.getInstance();
+ final token = prefs.getString('rommAuthToken');
+ if (token != null) {
+ await secureStorage.write(key: 'rommAuthToken', value: token);
+ await prefs.remove('rommAuthToken');
+ }
} catch (e) {
// Clear any stale token so Basic auth is used instead.
- final p = await SharedPreferences.getInstance();
- await p.remove('rommAuthToken');
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.remove('rommAuthToken');
+ await secureStorage.delete(key: 'rommAuthToken');
}
// Save credentials regardless of whether token fetch succeeded.
final prefs = await SharedPreferences.getInstance();
await prefs.setString('rommBaseUrl', baseUrl);
await prefs.setString('rommUsername', username);
- await prefs.setString('rommPassword', password);
+ await secureStorage.write(key: 'rommPassword', value: password);
+ await secureStorage.write(key: 'rommApiKey', value: apiKey);
// Invalidate providers to refresh RomM service and config
ref.invalidate(rommConfigProvider);
@@ -369,4 +410,4 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
],
);
}
-}
+}
\ No newline at end of file
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
index e71a16d..d0e7f79 100644
--- a/linux/flutter/generated_plugin_registrant.cc
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
+#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
+ g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
+ flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index 2e1de87..b29e9ba 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 42545f9..fcdae0a 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,11 +5,13 @@
import FlutterMacOS
import Foundation
+import flutter_secure_storage_macos
import package_info_plus
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 7a95e6d..16720af 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -198,6 +198,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
+ flutter_secure_storage:
+ dependency: "direct main"
+ description:
+ name: flutter_secure_storage
+ sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.2.4"
+ flutter_secure_storage_linux:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_linux
+ sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.3"
+ flutter_secure_storage_macos:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_macos
+ sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
+ flutter_secure_storage_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_platform_interface
+ sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ flutter_secure_storage_web:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_web
+ sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ flutter_secure_storage_windows:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_windows
+ sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -240,6 +288,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.7"
leak_tracker:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 8bc4258..9a1ebc5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -43,6 +43,7 @@ dependencies:
archive: ^4.0.9
file_picker: ^8.0.0+1
path: ^1.9.0
+ flutter_secure_storage: ^9.2.4
dev_dependencies:
flutter_test:
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index 8b6d468..0c50753 100644
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
+#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
+ FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index b93c4c3..4fc759c 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ flutter_secure_storage_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST