Skip to content

commit 2ce9c83

abduznik edited this page May 23, 2026 · 1 revision

chore: prepare v0.4.0 release with Steam Deck support and background sync

Commit: 2ce9c83b4ee8b9ddd9bb675b60094e6d035c58a1

Author: abduznik

Date: 2026-05-04

Why: Routine maintenance, dependency updates, version bumps, or tooling changes.

Files Changed

CHANGELOG.md                                  |  31 ++++++++
 README.md                                     |  15 ++--
 agent.md                                      |  10 ++-
 lib/core/romm/romm_service.dart               |   8 +-
 test/core/emulator/linux_strategies_test.dart | 106 ++++++++++++++++++++++++++
 test/unit/romm_service_test.dart              |  91 ++++++++++++++++++++++
 6 files changed, 250 insertions(+), 11 deletions(-)
  • CHANGELOG.md
  • README.md
  • agent.md
  • lib/core/romm/romm_service.dart
  • test/core/emulator/linux_strategies_test.dart
  • test/unit/romm_service_test.dart

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0da1b93
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,31 @@
+# Changelog
+
+## [0.4.0] - 2026-05-04
+
+### Added
+- **Steam Deck & Linux Support**: Full integration with **EmuDeck** and **RetroDECK** environments.
+  - Automatic detection of EmuDeck/RetroDECK folder structures.
+  - Support for SteamOS-specific launcher scripts (`.sh` files).
+  - High-precision path resolution for emulator saves, including Flatpak sandboxes and EmuDeck symlinks.
+- **Serial Background Sync**: Implementation of a background queue for game save backups. Offline saves are now automatically synchronized to RomM when a connection is restored.
+- **Recently Added Widget**: Optimized "Recently Added" section on the home screen, now sorted by RomM ID for true chronological discovery.
+- **Automated Linux Validation**: Comprehensive unit test suite for Linux path resolution strategies to ensure stability across SteamOS updates.
+
+### Fixed
+- **ROM Scanning**: Resolved issues with PS3 and Nintendo Switch ROM scanning and name normalization.
+- **Download Reliability**: Fixed filesystem access errors (errno 5) during game downloads on certain OS configurations.
+- **UI/UX Polishing**: Improved alignment and visual consistency in the Settings screen and Game Detail views.
+
+### Changed
+- Refactored Linux strategy logic into isolated, testable classes (`EmuDeckStrategy`, `RetroDeckStrategy`).
+- Optimized metadata caching for faster offline library browsing.
+
+---
+
+## [0.3.0] - 2026-04-20
+
+### Added
+- **macOS Texture Processing**: Support for Ryujinx asset processing and texture conversion.
+- **Multi-Platform Native Support**: Initial support for macOS and Windows.
+- **Save Sync**: Bidirectional sync for major emulators.
+- **BIOS Management**: Automatic BIOS placement and downloading.
diff --git a/README.md b/README.md
index a4aee6c..b688ecc 100644
--- a/README.md
+++ b/README.md
@@ -23,24 +23,25 @@ Freegosy is a solo passion project — built and maintained in my spare time, wi
 
 No pressure at all — the app is and will always be free.
 
-## Current Features (v0.3.x)
+## Current Features (v0.4.0)
 
-- **Native Multi-Platform Support**: Full support for **macOS** (ARM64/Intel), **Windows**, and **Linux** (including **Steam Deck/EmuDeck** integration).
+- **Native Multi-Platform Support**: Full support for **macOS** (ARM64/Intel), **Windows**, and **Linux** (including **Steam Deck/EmuDeck** and **RetroDECK** integration).
 - **Enhanced Offline Mode**: Persistent metadata caching for browsing and launching your collection without a connection.
 - **RomM Integration**: 
     - Browse and filter your entire library with server-side pagination.
-    - **New**: Instant "Downloaded" games filter with background deep collection scanning.
+    - Instant "Downloaded" games filter with background deep collection scanning.
     - Download ROMs directly via HTTP with real-time progress tracking.
     - Personal game properties support (rating, status, completion).
 - **Advanced Emulator Management**: 
     - Download, update, and uninstall emulators directly from Settings.
     - Automatic extraction of `.zip`, `.7z`, `.dmg`, `.tar.gz`, `.tar.xz`, and `.AppImage`.
     - Smart binary detection and canonical naming across all platforms.
-    - **New**: Dynamic architecture selection for RPCS3 on macOS (ARM64 vs x64).
+    - Dynamic architecture selection for RPCS3 on macOS (ARM64 vs x64).
+    - **New**: Linux Environment Strategies (Default, EmuDeck, RetroDECK) with automatic path detection.
 - **BIOS Management**: Fetch and download BIOS files directly from RomM and automatically place them in the correct directory for each emulator.
 - **Save Sync**: 
     - Bidirectional local-to-cloud save synchronization with RomM.
-    - **New**: Local Backup History (create instant restore points before experimenting).
+    - Local Backup History (create instant restore points before experimenting).
     - **New**: Serial Background Sync Queue (offline backups silently push to RomM automatically when you reconnect).
     - Optimized for EmuDeck's platform-specific save structure.
 - **Refined UI/UX**:
@@ -50,9 +51,9 @@ No pressure at all — the app is and will always be free.
     - **Multi-Disc Support**: Integrated picker for multi-file games.
 
 ## Calling All Testers!
-I am currently searching for testers on **macOS** and **Windows** to help polish the experience. 
+I am currently searching for testers on **macOS**, **Windows**, and **Linux (Steam Deck)** to help polish the experience. 
 
-- **Future Plans**: Steam Deck/Linux support is next, followed by **Android** for a truly unified app.
+- **Future Plans**: **Android** support is next for a truly unified app experience.
 - **Get Involved**: If you're interested in testing an early release, reach out via GitHub or join the community discussions.
 
 ## About RomM
diff --git a/agent.md b/agent.md
index d534a3f..48c92ab 100644
--- a/agent.md
+++ b/agent.md
@@ -50,10 +50,16 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 ### Core — Emulator
 - `lib/core/emulator/emulator_strategy.dart` — Abstract base class for launch logic.
 - `lib/core/emulator/emulator_registry_data.dart` — Static definitions for emulator downloads and filters.
-- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies with conflict detection.
+- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies with conflict detection. Added OS-based strategy filtering.
 - `lib/core/emulator/emulator_download_service.dart` — Downloads emulators from direct URLs or GitHub.
 - `lib/core/emulator/github_release_service.dart` — Resolves latest GitHub release assets.
-- `lib/core/emulator/strategies/` — Specific implementations for each emulator (RetroArch, Dolphin, Eden, RPCS3, PCSX2, Azahar, Cemu, DuckStation, Flycast, melonDS, PPSSPP, mGBA, MAME, Xemu, Xenia, Windows).
+- `lib/core/emulator/strategies/` — Specific implementations for each emulator (RetroArch, Dolphin, Eden, Ryujinx, RPCS3, PCSX2, Azahar, Cemu, DuckStation, Flycast, melonDS, PPSSPP, mGBA, MAME, Xemu, Xenia, Windows).
+- `lib/core/emulator/linux_strategies/` — SteamOS/Linux environment strategies.
+  - `linux_environment_strategy.dart` — Interface for ROM/Save/Tool path resolution on Linux.
+  - `emudeck_strategy.dart` — EmuDeck-specific resolution (SD card detection, symlink saves).
+  - `retrodeck_strategy.dart` — RetroDECK Flatpak resolution.
+  - `native_linux_strategy.dart` — Default Linux directory structure.
+  - `linux_native_game_service.dart` — Proton/Steam prefix path resolution for native PC games on Linux.
 
 ### Core — Extraction
 - `lib/core/extraction/extraction_service.dart` — Unified extraction for .zip, .7z, .dmg, .tar.gz, .tar.xz, and .exe. Sanitizes macOS .app bundles.
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index d6bd79c..2d36b4d 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -29,8 +29,8 @@ class RommService {
   static String _normalizeBaseUrl(String url) =>
       url.endsWith('/') ? url.substring(0, url.length - 1) : url;
 
-  RommService(this._config)
-      : _dio = Dio(BaseOptions(
+  RommService(this._config, {Dio? dio})
+      : _dio = dio ?? Dio(BaseOptions(
           baseUrl: _normalizeBaseUrl(_config.baseUrl),
           connectTimeout: const Duration(seconds: 5),
           receiveTimeout: const Duration(seconds: 5),
@@ -41,6 +41,10 @@ class RommService {
         )),
         _authOptions = _computeAuthOptions(_config) {
     
+    if (dio != null) {
+      _dio.options.baseUrl = _normalizeBaseUrl(_config.baseUrl);
+    }
+    
     if (kDebugMode || io.Platform.isLinux || io.Platform.isMacOS) {
       _dio.interceptors.add(InterceptorsWrapper(
         onRequest: (options, handler) {
diff --git a/test/core/emulator/linux_strategies_test.dart b/test/core/emulator/linux_strategies_test.dart
new file mode 100644
index 0000000..3b05502
--- /dev/null
+++ b/test/core/emulator/linux_strategies_test.dart
@@ -0,0 +1,106 @@
+import 'dart:io';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:path/path.dart' as p;
+import 'package:freegosy/core/emulator/linux_strategies/emudeck_strategy.dart';
+import 'package:freegosy/core/emulator/linux_strategies/retrodeck_strategy.dart';
+
+void main() {
+  group('Linux Environment Strategies Path Resolution', () {
+    late Directory tempHome;
+    late Directory emudeckRoot;
+    late Directory retrodeckVarConfig;
+    
+    late EmuDeckStrategy emudeckStrategy;
+    late RetroDeckStrategy retrodeckStrategy;
+
+    setUp(() async {
+      // Create a fake home directory
+      tempHome = await Directory.systemTemp.createTemp('freegosy_test_home_');
+      
+      // --- Setup Mock EmuDeck Structure ---
+      // EmuDeck normally lives in ~/Emulation or an SD card
+      emudeckRoot = Directory(p.join(tempHome.path, 'Emulation'));
+      await emudeckRoot.create();
+      
+      await Directory(p.join(emudeckRoot.path, 'roms')).create();
+      await Directory(p.join(emudeckRoot.path, 'tools', 'launchers')).create(recursive: true);
+      await Directory(p.join(emudeckRoot.path, 'bios')).create();
+      
+      // Mock EmuDeck Save symlink structure for Cemu
+      final cemuSaves = Directory(p.join(emudeckRoot.path, 'saves', 'Cemu', 'saves'));
+      await cemuSaves.create(recursive: true);
+
+      // Create a dummy launch script
+      final launcher = File(p.join(emudeckRoot.path, 'tools', 'launchers', 'cemu.sh'));
+      await launcher.writeAsString('#!/bin/bash');
+
+      // --- Setup Mock RetroDECK Structure ---
+      await Directory(p.join(tempHome.path, 'retrodeck', 'roms')).create(recursive: true);
+      await Directory(p.join(tempHome.path, 'retrodeck', 'tools')).create(recursive: true);
+      
+      // RetroDECK uses Flatpak standard paths
+      retrodeckVarConfig = Directory(p.join(tempHome.path, '.var', 'app', 'net.retrodeck.retrodeck', 'config'));
+      await Directory(p.join(retrodeckVarConfig.path, 'bios')).create(recursive: true);
+      
+      // Mock RetroDECK save structure for PCSX2
+      await Directory(p.join(retrodeckVarConfig.path, 'PCSX2', 'saves')).create(recursive: true);
+
+      // Initialize strategies
+      emudeckStrategy = EmuDeckStrategy();
+      retrodeckStrategy = RetroDeckStrategy();
+    });
+
+    tearDown(() async {
+      // Clean up the fake file tree
+      if (await tempHome.exists()) {
+        await tempHome.delete(recursive: true);
+      }
+    });
+
+    group('EmuDeckStrategy', () {
+      test('Resolves ROMs root correctly', () {
+        // EmuDeck Strategy checks for Emulation/roms
+        final result = emudeckStrategy.getRomsRoot(tempHome.path, null, tempHome.path);
+        expect(result, p.join(tempHome.path, 'Emulation', 'roms'));
+      });
+
+      test('Resolves Tools root correctly', () {
+        // EmuDeck Strategy checks for Emulation/tools
+        final result = emudeckStrategy.getEmulatorsRoot(tempHome.path, null, tempHome.path);
+        expect(result, p.join(tempHome.path, 'Emulation', 'tools'));
+      });
+
+      test('Resolves specific save symlink directory correctly', () {
+        // We mocked 'Cemu' and its 'saves' subfolder, which EmuDeckStrategy should prioritize
+        final result = emudeckStrategy.getEmulatorAppSupportDirectory(tempHome.path, 'cemu', tempHome.path);
+        expect(result, p.join(tempHome.path, 'Emulation', 'saves', 'Cemu', 'saves'));
+      });
+
+      test('Finds EmuDeck specific launcher script', () async {
+        // EmuDeck map maps 'cemu' to 'cemu.sh'
+        final result = await emudeckStrategy.findExecutable('cemu', 'Cemu.AppImage', tempHome.path, tempHome.path);
+        expect(result, p.join(tempHome.path, 'Emulation', 'tools', 'launchers', 'cemu.sh'));
+      });
+    });
+
+    group('RetroDeckStrategy', () {
+      test('Resolves ROMs root correctly', () {
+        // RetroDECK ROMs are in ~/retrodeck/roms
+        final result = retrodeckStrategy.getRomsRoot(tempHome.path, null, null);
+        expect(result, p.join(tempHome.path, 'retrodeck', 'roms'));
+      });
+
+      test('Resolves Flatpak BIOS directory correctly', () {
+        // RetroDECK bios are in ~/.var/app/net.retrodeck.retrodeck/config/bios
+        final result = retrodeckStrategy.getBiosPath(tempHome.path, null);
+        expect(result, p.join(tempHome.path, '.var', 'app', 'net.retrodeck.retrodeck', 'config', 'bios'));
+      });
+
+      test('Resolves Flatpak Save directory correctly', () {
+        // We mocked PCSX2 with a 'saves' subfolder inside the flatpak config
+        final result = retrodeckStrategy.getEmulatorAppSupportDirectory(tempHome.path, 'pcsx2', null);
+        expect(result, p.join(retrodeckVarConfig.path, 'PCSX2', 'saves'));
+      });
+    });
+  });
+}
diff --git a/test/unit/romm_service_test.dart b/test/unit/romm_service_test.dart
new file mode 100644
index 0000000..7caedfb
--- /dev/null
+++ b/test/unit/romm_service_test.dart
@@ -0,0 +1,91 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:dio/dio.dart';
+import 'package:http_mock_adapter/http_mock_adapter.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/romm/romm_service.dart';
+
+void main() {
+  late RommService rommService;
+  late Dio dio;
+  late DioAdapter dioAdapter;
+
+  const String testBaseUrl = 'https://romm.example.com';
+  const String testApiKey = 'test_api_key_12345';
+
+  setUp(() {
+    dio = Dio(BaseOptions(baseUrl: testBaseUrl));
+    dioAdapter = DioAdapter(dio: dio);
+    
+    final config = RomMConfig(
+      baseUrl: testBaseUrl,
+      username: '',
+      password: '',
+      apiKey: testApiKey,
+    );
+    
+    rommService = RommService(config, dio: dio);
+  });
+
+  group('RommService API Authentication', () {
+    test('sends API Key in both Authorization and X-Api-Key headers', () async {
+      // Setup mock response
+      dioAdapter.onGet(
+        '/api/platforms',
+        (server) => server.reply(200, {'items': []}),
+        headers: {
+          'Authorization': 'Bearer $testApiKey',
+          'X-Api-Key': testApiKey,
+        },
+      );
+
+      // Call the API
+      final platforms = await rommService.getPlatforms();
+
+      // Verify results
+      expect(platforms, isEmpty);
+    });
+
+    test('getPlatforms correctly parses platforms list', () async {
+      // Mock data
+      final mockData = {
+        'items': [
+          {
+            'id': 1,
+            'name': 'Nintendo Switch',
+            'slug': 'switch',
+            'display_name': 'Switch',
+            'rom_count': 10,
+          },
+          {
+            'id': 2,
+            'name': 'PlayStation 2',
+            'slug': 'ps2',
+            'display_name': 'PS2',
+            'rom_count': 5,
+          }
+        ]
+      };
+
+      dioAdapter.onGet(
+        '/api/platforms',
+        (server) => server.reply(200, mockData),
+      );
+
+      final platforms = await rommService.getPlatforms();
+
+      expect(platforms.length, 2);
+      expect(platforms[0].id, 1);
+      expect(platforms[0].name, 'Nintendo Switch');
+      expect(platforms[1].slug, 'ps2');
+    });
+
+    test('getPlatforms throws DioException on error response', () async {
+      dioAdapter.onGet(
+        '/api/platforms',
+        (server) => server.reply(401, {'message': 'Unauthorized'}),
+      );
+
+      expect(() => rommService.getPlatforms(), throwsA(isA<DioException>()));
+    });
+  });
+}

Clone this wiki locally