Skip to content

commit 403de54

abduznik edited this page May 23, 2026 · 1 revision

feat: API key authentication for RomM 4.8+ (#6)

Commit: 403de54fed0d1fb9f1be51cf243bfe1c63668f05

Author: abduznik

Date: 2026-03-27

Message

  • 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.

Files Changed

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.dart
  • lib/core/romm/romm_service.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_screen.dart
  • lib/ui/screens/settings_screen.dart
  • linux/flutter/generated_plugin_registrant.cc
  • linux/flutter/generated_plugins.cmake
  • macos/Flutter/GeneratedPluginRegistrant.swift
  • pubspec.lock
  • pubspec.yaml
  • windows/flutter/generated_plugin_registrant.cc
  • windows/flutter/generated_plugins.cmake

Diff

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

Clone this wiki locally