Skip to content

commit e39958e

abduznik edited this page May 23, 2026 · 1 revision

v0.2.0: macOS Support & UI Improvements (#8)

Commit: e39958e6bb7a6e0147a18b208d2e696e340d2968

Author: abduznik

Date: 2026-03-30

Message

  • feat(macos): add macOS platform support
  • Add macOS target to emulator registry (PPSSPP, RetroArch)
  • Add macos_url, macos_executable, macos_supported_platforms fields
  • Add macosExecutable getter to EmulatorStrategy base class
  • Add Platform.isMacOS branch to strategy registry filter
  • Add Platform.isMacOS branch to emulator download service
  • Add .dmg extraction via hdiutil attach/detach
  • Add macOS zip extraction via system unzip (preserves symlinks/perms)
  • Auto codesign and remove quarantine after extraction
  • Fix RetroArch launch to use platform-correct paths and separators
  • Fix RetroArch core map to use .dylib on macOS/Linux
  • Fix RetroArch core download URL for macOS (arm64 buildbot)
  • Fix directory service to use getApplicationSupportDirectory on macOS
  • Fix emulator detection to use macos_executable on macOS
  • Fix ROM download state persistence across tab switches
  • Fix setState after dispose in emulator download listener
  • Disable app sandbox in DebugProfile for dev builds
  • fix(macos): resolve static analysis warnings
  • Fix duplicate dart:io import in ppsspp_strategy
  • Remove unused Directory from retroarch_strategy show clause
  • Remove unused default path constants from directory_service
  • feat(macos): add PPSSPP save sync support for macOS
  • Fix save path to use ~/.config/ppsspp/PSP on macOS/Linux
  • Enable supportsSaveSync in PPSSPPStrategy
  • Platform-aware separator and path resolution
  • feat(macos): add PPSSPP save sync support for macOS
  • Fix save path to use ~/.config/ppsspp/PSP on macOS/Linux
  • Use path.join() for cross-platform path construction
  • Enable supportsSaveSync in PPSSPPStrategy
  • feat: implement Dolphin support and macOS extraction fixes

  • feat(macos): implement full Dolphin support with save synchronization

  • Added Dolphin to emulator registry with stable macOS DMG source
  • Implemented DolphinStrategy with macOS-specific executable paths
  • Added hdiutil mounting and Gatekeeper (xattr/codesign) bypass in ExtractionService
  • Implemented cross-platform save path resolution for Dolphin (macOS/Windows)
  • Integrated fuzzy-search logic for GameCube .gci save file synchronization
  • Verified successful save push/pull with RomM server on macOS
  • feat: Eden save sync overhaul + versioned save slots

Eden (Switch) save strategy:

  • Rewrote profile resolution: scans actual save file mtimes instead of directory mtime, eliminates ghost profile bug
  • Three-layer Title ID resolution: ROM header scan → filename extraction → manual mapping → throw
  • Tokenized fuzzy ROM path matching for multi-file and missing-extension cases
  • ProfileConflictException with UI dialog for ambiguous profiles
  • getSaveDir now returns path even if directory doesn't exist yet (needed for pull)
  • getSaveFiles validates directory contents and respects sessionStart filter

Save sync service:

  • Timestamped save slots (freegosy-srm_YYYY-MM-DD_HH-MM-SS) instead of overwriting single slot
  • Auto-pruning keeps only 5 most recent saves per game in RomM
  • .freegosy_sync metadata file in zips to prevent RomM content_hash deduplication
  • Added force parameter to pushSaves and clearHashCache method
  • Manual push button always uploads (force=true), auto-sync still deduplicates

Affects all platforms:

  • Versioned slots and pruning apply to every emulator strategy
  • Hash dedup fix only triggers for directory-based saves (Eden, Dolphin, RPCS3, Xenia)
  • polish: clean up debug prints, one-row button layout, fuzzy dir matching
  • Removed verbose API request/response debug prints from romm_service.dart
  • Removed diagnostic sync service prints from save_sync_service.dart
  • Kept permanent operational logs (upload confirmations, errors, pruning)
  • Game card buttons now single row: Play | Push | Pull | Delete
  • Eden ROM resolver now fuzzy-matches directories for multi-file ROMs
  • Title ID filename extraction uses resolved ROM path instead of original
  • feat: add partial support for 3DS emulation on macOS and Windows
  1. Created AzaharSaveStrategy with manual folder mapping and zip-based sync.
  2. Integrated Azahar strategy into SaveSyncService and updated slug mapping logic.
  3. Updated EmulatorDownloadService to support platform-specific GitHub asset filters (required/excluded).
  4. Expanded emulator_registry_data with macOS-specific 3DS configurations and executable paths.
  5. Enabled 3DS core support in RetroArchStrategy with dynamic core pathing and system folder automation.
  6. Added automatic 3DS shared font downloader and system directory management in DirectoryService.
  7. Improved StrategyRegistry grouping logic to sync preferences across related platform slugs (e.g., 3ds and new-nintendo-3ds).
  8. Generalized the folder mapping UI in LibraryScreen to support multiple manual-mapping strategies.
  • feat: add RPCS3 macOS support and integrate 7zz for macOS
  • Added RPCS3 ARM64 GitHub repository and asset filters to registry.
  • Updated EmulatorDownloadService to handle platform-specific GitHub repository overrides.
  • Integrated bundled 7zz binary for macOS .7z extraction with executable permission handling.
  • Added 7zz to pubspec assets.
  • feat: implement standalone emulator launching architecture and UI
  • Added launchStandalone() to EmulatorStrategy abstract class.
  • Added getStrategyById() to StrategyRegistry.
  • Implemented launchStandalone() in all 16 emulator strategies.
  • Refined macOS standalone launching to use the 'open' command for .app bundles.
  • Added 'Launch Standalone' button to the emulator settings section.
  • feat: improve 3DS emulation and update project documentation
  • Implemented automated 3DS shared font downloader in Azahar and RetroArch strategies.
  • Added pre-launch 3DS aes_keys.txt existence check in LibraryScreen.
  • Added automated 3DS system directory management in DirectoryService.
  • Fixed async gap issues with ScaffoldMessenger in settings UI.
  • Updated agent.md with recent architecture changes and file mappings.
  • feat: refine standalone launcher and system paths for Windows compatibility
  • Updated all emulator strategies to set workingDirectory for standalone launches on Windows.
  • Implemented Windows APPDATA resolution for Azahar system directory.
  • Refined PCSX2 strategy with platform-aware path normalization.
  • Resolved io.Platform naming conflicts in all strategy files.
  • feat: implement full cross-platform PCSX2 support with save sync
  • Added .tar.xz extraction support and automatic .app bundle canonical renaming.
  • Implemented cross-platform PCSX2 save strategy using system directories.
  • Refined emulator detection logic to handle versioned folder names on macOS.
  • Enabled PCSX2 save synchronization with RomM.
  • feat: implement full cross-platform DuckStation support with save sync
  • Added macOS support to DuckStation registry and strategy.
  • Implemented macOS-specific save directory resolution for DuckStation.
  • Refined DuckStation save strategy with cross-platform path handling.
  • Enabled native .app bundle launching for DuckStation on macOS.
  • fix: update DuckStation macOS asset filters to match official naming

  • feat: fix platform filtering by supporting multiple game count field names

  • Updated Platform model to check for rom_count, roms_count, and games_count.
  • Updated library cache to force a refresh with the new logic.
  • Platforms with zero games are now correctly filtered out.
  • feat: complete macOS support for CEMU, MGBA, Flycast, and melonDS
  • Unified extraction sanitization and versioned .app renaming.
  • Added macOS registry definitions and strategy updates for remaining emulators.
  • Refined CEMU, MGBA, and Azahar save strategies for cross-platform compatibility.
  • Cleaned up extraction service code and verified with flutter analyze.
  • fix: recursive Azahar detection and case-insensitive bundle matching
  • Updated DirectoryService to search for .app bundles recursively (handles nested extraction folders).
  • Switched ExtractionService to use case-insensitive 'find -iname' for .app sanitization.
  • Enhanced debug logging for emulator executable discovery.
  • fix: use 'open' command for launching Azahar with ROM on macOS

  • feat: add emulator uninstall/update buttons and platform filtering

  • Added deleteEmulator method to DirectoryService.
  • Implemented Uninstall (trash bin) and Update (download icon) buttons in settings.
  • Added platform filtering to the Emulators settings section to only show relevant emulators.
  • Refactored download logic for better reuse.
  • chore: bump version to 0.2.0 and update documentation
  • Updated README.md and agent.md with version 0.2.0 features.
  • Bumped version in pubspec.yaml.
  • Cleaned up unused imports.
  • chore: add 7-Zip license and Third-Party Licenses section
  • Added thirdparty/7zip_license.txt.
  • Integrated LicenseRegistry and showLicensePage in SettingsScreen.
  • Registered 7-Zip as a third-party license for compliance.
  • Removed unused screenshot from git tracking.

Why: General improvement or change to the codebase.

Files Changed

.gitignore                                         |   1 +
 README.md                                          | 100 +--
 agent.md                                           | 128 ++--
 ios/Flutter/Debug.xcconfig                         |   1 +
 ios/Flutter/Release.xcconfig                       |   1 +
 ios/Podfile                                        |  43 ++
 lib/core/downloader/download_service.dart          |  43 +-
 lib/core/emulator/emulator_download_service.dart   |  49 +-
 lib/core/emulator/emulator_registry_data.dart      |  81 ++-
 lib/core/emulator/emulator_strategy.dart           |   3 +
 lib/core/emulator/strategies/azahar_strategy.dart  |  93 ++-
 lib/core/emulator/strategies/cemu_strategy.dart    |  37 +-
 lib/core/emulator/strategies/dolphin_strategy.dart |  62 +-
 .../emulator/strategies/duckstation_strategy.dart  |  47 +-
 lib/core/emulator/strategies/eden_strategy.dart    |  96 ++-
 lib/core/emulator/strategies/flycast_strategy.dart |  41 +-
 lib/core/emulator/strategies/mame_strategy.dart    |  16 +
 lib/core/emulator/strategies/melonds_strategy.dart |  43 +-
 lib/core/emulator/strategies/mgba_strategy.dart    |  28 +
 lib/core/emulator/strategies/pcsx2_strategy.dart   |  71 +-
 lib/core/emulator/strategies/ppsspp_strategy.dart  |  29 +-
 .../emulator/strategies/retroarch_strategy.dart    | 235 ++++++-
 lib/core/emulator/strategies/rpcs3_strategy.dart   |  41 +-
 lib/core/emulator/strategies/windows_strategy.dart |   3 +
 lib/core/emulator/strategies/xemu_strategy.dart    |  10 +
 lib/core/emulator/strategies/xenia_strategy.dart   |  10 +
 lib/core/emulator/strategy_registry.dart           |  15 +
 lib/core/extraction/extraction_service.dart        | 248 ++++++-
 lib/core/romm/romm_models.dart                     |  24 +
 lib/core/romm/romm_service.dart                    |  92 ++-
 lib/core/save/save_strategy.dart                   |  12 +
 lib/core/save/save_sync_service.dart               | 116 +++-
 lib/core/save/strategies/azahar_save_strategy.dart | 237 +++++++
 lib/core/save/strategies/cemu_save_strategy.dart   |  32 +-
 .../save/strategies/dolphin_save_strategy.dart     | 209 +++---
 .../save/strategies/duckstation_save_strategy.dart |  37 +-
 lib/core/save/strategies/eden_save_strategy.dart   | 769 +++++++++++++++++----
 lib/core/save/strategies/mgba_save_strategy.dart   |  37 +-
 lib/core/save/strategies/pcsx2_save_strategy.dart  |  59 +-
 lib/core/save/strategies/ppsspp_save_strategy.dart |  67 +-
 lib/core/save/strategies/rpcs3_save_strategy.dart  |  22 +-
 lib/core/storage/directory_service.dart            | 254 +++++--
 lib/providers/library_provider.dart                |   7 +-
 lib/ui/screens/library_screen.dart                 | 404 ++++++++++-
 lib/ui/screens/settings_emulators_section.dart     | 160 ++++-
 lib/ui/screens/settings_screen.dart                |  46 +-
 lib/ui/widgets/game_card.dart                      | 110 ++-
 macos/Flutter/Flutter-Debug.xcconfig               |   1 +
 macos/Flutter/Flutter-Release.xcconfig             |   1 +
 macos/Podfile                                      |  42 ++
 macos/Podfile.lock                                 |  42 ++
 macos/Runner.xcodeproj/project.pbxproj             | 103 ++-
 macos/Runner.xcworkspace/contents.xcworkspacedata  |   3 +
 macos/Runner/DebugProfile.entitlements             |  18 +-
 macos/Runner/Release.entitlements                  |  16 +-
 pubspec.yaml                                       |   4 +-
 test/game_card_test.dart                           |   1 +
 thirdparty/7zip_license.txt                        |  56 ++
 thirdparty/7zz                                     | Bin 0 -> 5939184 bytes
 59 files changed, 3746 insertions(+), 810 deletions(-)
  • .gitignore
  • README.md
  • agent.md
  • ios/Flutter/Debug.xcconfig
  • ios/Flutter/Release.xcconfig
  • ios/Podfile
  • lib/core/downloader/download_service.dart
  • lib/core/emulator/emulator_download_service.dart
  • lib/core/emulator/emulator_registry_data.dart
  • lib/core/emulator/emulator_strategy.dart
  • lib/core/emulator/strategies/azahar_strategy.dart
  • lib/core/emulator/strategies/cemu_strategy.dart
  • lib/core/emulator/strategies/dolphin_strategy.dart
  • lib/core/emulator/strategies/duckstation_strategy.dart
  • lib/core/emulator/strategies/eden_strategy.dart
  • lib/core/emulator/strategies/flycast_strategy.dart
  • lib/core/emulator/strategies/mame_strategy.dart
  • lib/core/emulator/strategies/melonds_strategy.dart
  • lib/core/emulator/strategies/mgba_strategy.dart
  • lib/core/emulator/strategies/pcsx2_strategy.dart
  • lib/core/emulator/strategies/ppsspp_strategy.dart
  • lib/core/emulator/strategies/retroarch_strategy.dart
  • lib/core/emulator/strategies/rpcs3_strategy.dart
  • lib/core/emulator/strategies/windows_strategy.dart
  • lib/core/emulator/strategies/xemu_strategy.dart
  • lib/core/emulator/strategies/xenia_strategy.dart
  • lib/core/emulator/strategy_registry.dart
  • lib/core/extraction/extraction_service.dart
  • lib/core/romm/romm_models.dart
  • lib/core/romm/romm_service.dart
  • lib/core/save/save_strategy.dart
  • lib/core/save/save_sync_service.dart
  • lib/core/save/strategies/azahar_save_strategy.dart
  • lib/core/save/strategies/cemu_save_strategy.dart
  • lib/core/save/strategies/dolphin_save_strategy.dart
  • lib/core/save/strategies/duckstation_save_strategy.dart
  • lib/core/save/strategies/eden_save_strategy.dart
  • lib/core/save/strategies/mgba_save_strategy.dart
  • lib/core/save/strategies/pcsx2_save_strategy.dart
  • lib/core/save/strategies/ppsspp_save_strategy.dart
  • lib/core/save/strategies/rpcs3_save_strategy.dart
  • lib/core/storage/directory_service.dart
  • lib/providers/library_provider.dart
  • lib/ui/screens/library_screen.dart
  • lib/ui/screens/settings_emulators_section.dart
  • lib/ui/screens/settings_screen.dart
  • lib/ui/widgets/game_card.dart
  • macos/Flutter/Flutter-Debug.xcconfig
  • macos/Flutter/Flutter-Release.xcconfig
  • macos/Podfile
  • macos/Podfile.lock
  • macos/Runner.xcodeproj/project.pbxproj
  • macos/Runner.xcworkspace/contents.xcworkspacedata
  • macos/Runner/DebugProfile.entitlements
  • macos/Runner/Release.entitlements
  • pubspec.yaml
  • test/game_card_test.dart
  • thirdparty/7zip_license.txt
  • thirdparty/7zz

Diff

diff --git a/.gitignore b/.gitignore
index ea0b2ab..69a85da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,4 @@ app.*.map.json
 /android/app/profile
 /android/app/release
 .aider*
+Documents/
diff --git a/README.md b/README.md
index 86ec252..6a59551 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,26 @@
 
 A cross-platform Flutter app for browsing your RomM library, downloading ROMs, and launching games directly in emulators—all from one intuitive interface.
 
+## What's New in 0.2.0
+
+### Full macOS Support
+- Native support for macOS (ARM64 and Intel)
+- Automated `.app` bundle detection and stable launching via the `open` command
+- Native `.dmg` and `.tar.xz` extraction support
+- Platform-specific path resolution for Application Support and User directories
+- Fully enabled cross-platform Save Sync for macOS users
+
+### Enhanced Emulator Management
+- **One-Tap Uninstall**: New red trash bin button in Settings to completely remove an emulator and its local files
+- **Easy Updates**: New blue update button to fetch the latest release from GitHub/Direct links
+- **Smart Filtering**: The Emulators list in Settings now only shows emulators supported on your current OS (Windows/macOS/Linux)
+
+### UI & UX Improvements
+- **Clean Filter Bar**: Platforms with zero games are now automatically filtered out of the main library view
+- **Automatic Renaming**: Extracted versioned emulators (e.g. `PCSX2-v2.6.3.app`) are automatically renamed to their canonical names (`PCSX2.app`) for stable detection
+- **Recursive Discovery**: Deep search for `.app` bundles allows Freegosy to find emulators even when they are extracted into nested folders
+- **Flexible RomM Integration**: Added support for multiple game-count field names (`rom_count`, `roms_count`, `games_count`) for better compatibility with different RomM versions
+
 ## What's New in 0.1.2
 
 ### Paginated Library Loading
@@ -14,66 +34,13 @@ A cross-platform Flutter app for browsing your RomM library, downloading ROMs, a
 - Fixed RomM 4.x API compatibility — correctly uses `platform_ids` parameter
 - Platform results are cached in memory for instant switching on revisit
 
-### Search Improvements
-- Search is now debounced — waits 300ms after typing before firing the API call
-- No more per-keystroke requests hammering your RomM server
-
-### UX Fixes
-- F5 refresh now correctly shows the loading animation and clears cache
-- Pull-to-refresh always fetches fresh data from the server
-
-## What's New in 0.1.1
-
-- API key token verification improvements
-
-## What's New in 0.1.0
-
-### New Emulator Support
-- **Flycast** (Dreamcast, Naomi, Atomiswave)
-- **melonDS** (Nintendo DS)
-- **PPSSPP** standalone (PlayStation Portable)
-- **mGBA** standalone (Game Boy Advance, GBC, GB)
-- **MAME** (Arcade)
-
-### Expanded Save Sync
-- **DuckStation** (PS1): Memory card save sync
-- **melonDS** (NDS): Save file sync
-- **mGBA** (GBA/GBC/GB): Save file sync
-- **PPSSPP** (PSP): Entire SAVEDATA directory with automatic folder structure restoration
-- **Cemu** (Wii U): Full mlc01/usr/save/ directory packaging and restore
-- All new strategies support both push (upload) and pull (download) operations
-
-### Auto-Sync on Game Close
-- Saves automatically push to cloud when you close the emulator
-- Available for all supported emulators via `launchWithHandle()`
-- Seamless background operation—no manual intervention needed
-- Works alongside pre-launch save push and manual sync
-
-### Smart Emulator Management
-- **Conflict Resolver**: When multiple emulators support the same platform, choose which one to use in Settings
-- **Path Overrides**: Point Freegosy to existing emulator installations via Settings folder icon (no need to re-download)
-- **Preference Persistence**: Your emulator choices and custom paths are saved across app restarts
-
-### RetroArch Core Auto-Download
-- Missing cores are detected at launch time
-- User is offered to auto-download and install the required core
-- Full core list support via RetroArch buildbot
-
-### Performance & UX Enhancements
-- Library grid renders without blocking—0% CPU while idle
-- Cached images (memCache) for fast card rendering
-- Optimized grid with extended cache extent and dual-stem state file matching for RetroArch
-- **Library Display Presets**: Windows, Steam Deck, Cozy, Compact—quick-switch your layout
-- **Launch Status Snackbars**: Real-time feedback showing "Pushing saves / Syncing saves / Launching / Auto-syncing" at each stage
-- **Downloads Tab**: Emulator downloads now show alongside game downloads with progress tracking
-
 ## Currently Working
 
 - **RomM Integration**: Browse and filter your entire RomM library by platform or search
 - **ROM Downloads**: Download games via HTTP from your RomM server with progress tracking
-- **Archive Extraction**: Automatic extraction of .zip and .7z archives on download
+- **Archive Extraction**: Automatic extraction of .zip, .7z, .dmg, .tar.gz, and .tar.xz archives
 - **Game Launching**: Launch games directly from the app using:
-  - RetroArch (GBA, GBC, GB, NES, SNES, N64, NDS, PSX, PSP, Dreamcast, Megadrive, and more)
+  - RetroArch (All major retro platforms)
   - Dolphin (GameCube & Wii)
   - Eden (Nintendo Switch)
   - RPCS3 (PlayStation 3)
@@ -89,28 +56,13 @@ A cross-platform Flutter app for browsing your RomM library, downloading ROMs, a
   - mGBA (Game Boy Advance/Color/Game Boy)
   - MAME (Arcade)
   - Windows Native (PC games)
-- **Emulator Downloads**: One-tap emulator download and installation from Settings
-- **Save Sync**: Bidirectional save synchronization with RomM cloud for:
-  - RetroArch (all supported platforms)
-  - Dolphin (GameCube/Wii)
-  - Eden (Switch)
-  - PCSX2 (PS2)
-  - RPCS3 (PS3)
-  - DuckStation (PS1)
-  - melonDS (NDS)
-  - mGBA (GBA/GBC/GB)
-  - PPSSPP (PSP)
-  - Cemu (Wii U)
-  - Xenia Canary (Xbox 360)
-  - Windows native games (via PCGamingWiki auto-detection)
+- **Emulator Management**: Download, update, and uninstall emulators directly from Settings
+- **Save Sync**: Bidirectional save synchronization with RomM cloud for almost all supported emulators including cross-platform path resolution.
 
 ## Roadmap
 
-### In Progress
-- **Linux/macOS support** — Platform detection and path resolution already structured; adding platform-specific executable paths and environment variable resolution
-
 ### Near Term
-- **Auto-update emulators** — Keep emulators fresh without manual downloads
+- **Linux support** — Enhancing native binary detection and system path resolution
 - **Android support** — Deep links to app stores for Play Store/Epic Games/etc.
 - **Recently played / play time tracking** — See your gaming stats at a glance
 
@@ -123,7 +75,7 @@ Freegosy is designed as a truly cross-platform experience. The codebase is struc
 
 ## Status
 
-Actively under development. Release 0.1.2 brings paginated library loading and server-side platform filtering for large library support.
+Actively under development. Release 0.2.0 brings full macOS support and major UI management improvements.
 
 ## About RomM
 
diff --git a/agent.md b/agent.md
index 25d724e..2eb7225 100644
--- a/agent.md
+++ b/agent.md
@@ -14,7 +14,7 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 - New screen = new file in `ui/screens/`.
 - New reusable widget = new file in `ui/widgets/`.
 - Providers are thin — they call services, they do not contain business logic.
-- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `Process.run('cmd', ['/c', 'echo %APPDATA%'])` for env vars, and `defaultTargetPlatform == TargetPlatform.windows` for platform checks.
+- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `import 'dart:io' as io;` and `io.Platform` or `defaultTargetPlatform == TargetPlatform.windows`.
 
 ## File Map
 
@@ -23,131 +23,97 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin
 - `lib/app.dart` — MaterialApp setup, theme, initial route, navigation shell.
 
 ### Core — RomM
-- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getAllGames(), getGamesPage(offset, limit, platformId, search), getSaves(), uploadSave(), getLatestSave(), downloadSave(). getGamesPage() uses platform_ids (array), order_by, order_dir params per RomM 4.x API spec. Returns typed models.
-- `lib/core/romm/romm_models.dart` — Data models: Game, Platform, SaveFile, RomMConfig.
+- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getAllGames(), getGamesPage(offset, limit, platformId, search), getSaves(), uploadSave(), getLatestSave(), downloadSave(), pruneOldSaves(). getGamesPage() uses platform_ids (array), order_by, order_dir params per RomM 4.x API spec. Returns typed models.
+- `lib/core/romm/romm_models.dart` — Data models: Game, Platform (with gamesCount and flexible parsing), SaveFile, RomMConfig.
 
 ### Core — Save Sync
 - `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() keeps max 3 clean versions (.bak, .bak1, .bak2) — never creates chained .bak.bak files., getRomStem().
 - `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). getStrategyForSlug() checks StrategyRegistry user preferences first before falling back to platform slug defaults. Accepts StrategyRegistry as constructor parameter. Wires all strategies to RommService. Exposes windowsSaveStrategy for external access.
 - `lib/core/save/strategies/retroarch_save_strategy.dart` — RetroArch save strategy (GBA/GBC/GB/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive). Reads saves/{coreName}/{stem}.srm and .state.auto. PSP special case returns entire saves/PPSSPP/PSP directory as zip. All platforms use dual-stem (getRomStem + romPath filename) for state file matching. Directory existence check handles both File and Directory types.
-- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci.
+- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci. Supports macOS via ~/Library/Application Support/Dolphin resolution.
 - `lib/core/save/strategies/eden_save_strategy.dart` — Eden/Switch save strategy. Resolves title ID via filename regex, XCI header parse, or save folder scan. Zips save folder for upload.
+- `lib/core/save/strategies/azahar_save_strategy.dart` — Azahar/3DS save strategy. Uses manual folder mapping and zip-based sync for SDMC data. Resolves system directory via DirectoryService.
 - `lib/core/save/strategies/windows_save_strategy.dart` — Windows native game save strategy. Uses PCGamingWiki API for auto-detection, supports manual override. Zips entire save directory for upload, extracts on restore. Persists overrides via SharedPreferences (prefix `win_save_`).
-- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2. States: {emulatorDir}/sstates/{stem}.000 etc.
-- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA%
pcs3\dev_hdd0\home\00000001\savedata\{titleId}\. Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload. Uses Process.run for APPDATA resolution.
+- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {systemDir}/memcards/*.ps2. States: {systemDir}/sstates/{stem}.*. Supports cross-platform system directory resolution (Application Support on macOS, APPDATA on Windows).
+- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA%pcs3\dev_hdd0\home\00000001\savedata\{titleId}\ (Windows) or ~/Library/Application Support/rpcs3/... (macOS). Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload.
 - `lib/core/save/strategies/xenia_save_strategy.dart` — Xenia Canary save strategy (Xbox 360). Saves at {emulatorDir}\content\{titleId}\00000001\. Extracts title ID via 8-char hex regex. Zips save folder for upload.
-- `lib/core/save/strategies/duckstation_save_strategy.dart` — DuckStation save strategy (PS1). Checks for portable.txt in emulator dir — if present uses {emulatorDir}/memcards/{stem}.mcd, otherwise falls back to %LOCALAPPDATA%\DuckStation\memcards\{stem}.mcd.
+- `lib/core/save/strategies/duckstation_save_strategy.dart` — DuckStation save strategy (PS1). Memcards: {baseDir}/memcards/{stem}.mcd. Savestates: {baseDir}/savestates/{stem}.*. Supports cross-platform base directory resolution (Application Support on macOS, LOCALAPPDATA on Windows, or portable mode).
 - `lib/core/save/strategies/melonds_save_strategy.dart` — melonDS save strategy (NDS). Saves .sav file next to ROM, derived from actual romPath filename not game name.
 - `lib/core/save/strategies/mgba_save_strategy.dart` — mGBA save strategy (GBA/GBC/GB). Saves .sav file next to ROM, derived from actual romPath filename.
 - `lib/core/save/strategies/ppsspp_save_strategy.dart` — PPSSPP save strategy (PSP). Saves at {emulatorDir}/memstick/PSP/SAVEDATA/, states at PPSSPP_STATE/. Returns entire PSP/SAVEDATA directory as zip. Restore strips top-level SAVEDATA folder to extract directly into memstick/PSP/SAVEDATA/.
-- `lib/core/save/strategies/cemu_save_strategy.dart` — Cemu save strategy (Wii U). Zips {emulatorDir}/mlc01/usr/save/00050000/ for upload. Restore extracts zip directly into mlc01/usr/save/ skipping .bak entries.
+- `lib/core/save/strategies/cemu_save_strategy.dart` — Cemu save strategy (Wii U). Zips {emulatorDir}/mlc01/usr/save/00050000/ for upload. Restore extracts zip directly into mlc01/usr/save/ skipping .bak entries. Cross-platform path resolution.
 
 ### Core — Emulator
-- `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform(). Optional launchWithHandle(Game, romPath) method returns Process handle for auto-sync tracking. Default returns null.
-- `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions. Includes RetroArch, Dolphin, Eden, RPCS3, PCSX2, Azahar, Cemu, Xemu, Xenia, DuckStation, Flycast, melonDS, PPSSPP, mGBA, MAME.
-- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Includes conflict detection via detectConflicts() returning Map<String, List<EmulatorStrategy>> grouped by canonical platform name. setPreference(slug, emulatorId) and loadPreferences() persist user preferences under SharedPreferences key prefix 'emulator_pref_'. loadPreferences() is called on startup in romm_provider.dart to ensure conflict preferences are available before first launch.
-- `lib/core/emulator/emulator_download_service.dart` — Service for downloading emulators. Supports direct URL and GitHub release types. Uses ExtractionService for all extraction.
+- `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable, macosExecutable. Methods: launch(Game, romPath), launchStandalone(), resolveSavePath(Game), getExecutableForPlatform(). Optional launchWithHandle(Game, romPath) returns Process handle for auto-sync.
+- `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions. Supports platform-specific GitHub repo keys (github_repo_macos) and asset filters (github_asset_required_macos).
+- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Includes conflict detection and canonical slug grouping. getStrategyById(id) provides direct strategy access.
+- `lib/core/emulator/emulator_download_service.dart` — Service for downloading emulators. Supports direct URL and platform-aware GitHub release resolution.
 - `lib/core/emulator/github_release_service.dart` — Fetches latest release asset URL from GitHub API with required/excluded name filters.
-- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Slugs: gba/gbc/gb/nes/snes/n64/nds/psx/ps1/psp/dc/dreamcast/megadrive/genesis/md etc. Throws MissingRetroArchCoreException when a core .dll is missing. Has downloadCore() method to fetch from RetroArch buildbot.
-- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc.
-- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns.
-- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3.
-- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2.
-- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl.
-- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu.
-- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx.
-- `lib/core/emulator/strategies/flycast_strategy.dart` — Flycast strategy. Slugs: dc/dreamcast/naomi/naomi2/atomiswave/cave/hikaru.
-- `lib/core/emulator/strategies/melonds_strategy.dart` — melonDS strategy. Slugs: nds/nintendo-ds/ds.
-- `lib/core/emulator/strategies/ppsspp_strategy.dart` — PPSSPP strategy. Slugs: psp/playstation-portable. Implements launchWithHandle() returning Process with ProcessStartMode.normal for auto-sync on game close.
-- `lib/core/emulator/strategies/mgba_strategy.dart` — mGBA strategy. Slugs: gba/gbc/gb/game-boy-advance/game-boy-color/game-boy.
+- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Automates 3DS setup (citra/sysdata/config) and downloads shared_font.bin from mirror. Standalone launch uses 'open' for macOS .app.
+- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3. Supports macOS native binaries and .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl. Automates 3DS shared font download and system directory resolution. Launch via 'open -a ... --args' on macOS.
+- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/flycast_strategy.dart` — Flycast strategy. Slugs: dc/dreamcast/naomi/naomi2/atomiswave/cave/hikaru. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/melonds_strategy.dart` — melonDS strategy. Slugs: nds/nintendo-ds/ds. Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/ppsspp_strategy.dart` — PPSSPP strategy. Slugs: psp/playstation-portable. Implements launchWithHandle(). Supports macOS .app bundle launching via 'open'.
+- `lib/core/emulator/strategies/mgba_strategy.dart` — mGBA strategy. Slugs: gba/gbc/gb/game-boy-advance/game-boy-color/game-boy. Supports macOS .app bundle launching via 'open'.
 - `lib/core/emulator/strategies/mame_strategy.dart` — MAME strategy. Slugs: arcade/mame. Handles self-extracting .exe downloads.
-- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox.
+- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox. Supports macOS .app bundle launching via 'open'.
 - `lib/core/emulator/strategies/xenia_strategy.dart` — Xenia Canary strategy. Slugs: xbox360/xbla.
-- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder, validates stored override exists on disk before using. Launches via Process.start. Monitors exit code for 5s — throws if crashed. Persists overrides via SharedPreferences (prefix `win_exe_`).
+- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder.
 
 ### Core — Extraction
-- `lib/core/extraction/extraction_service.dart` — Unified extraction service. Handles .zip via archive package, .7z via bundled 7zr.exe, and self-extracting .exe files.
+- `lib/core/extraction/extraction_service.dart` — Unified extraction service. Handles .zip, .7z (via bundled 7zr.exe/7zz), .dmg (macOS), .tar.gz, .tar.xz, and self-extracting .exe files. Sanitizes macOS .app bundles (chmod, xattr, codesign). Automatically renames versioned bundles to canonical names (e.g. PCSX2-v2.6.3.app -> PCSX2.app).
 
 ### Core — Downloader
-- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Uses ExtractionService for all extraction.
+- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI.
 
 ### Core — Storage
-- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. setEmulatorPathOverride()/getEmulatorPathOverride()/loadEmulatorPathOverrides() allow users to point any emulator to a custom installation directory, persisted under SharedPreferences key prefix 'emu_path_'. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets.
+- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. resolveSevenZipPath() extracts bundled 7zr.exe (Windows) or 7zz (macOS). findEmulatorExecutable() handles recursive bundle discovery and case-insensitive matching on macOS. deleteEmulator() handles uninstallation.
 
 ### Core — Windows
 - `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder. Launches via Process.start detached.
 - `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations.
 
-### Core — Updater
-- `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads and relaunch.
-
 ### Providers
-- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry, SaveSyncService. strategyRegistryProvider and saveSyncServiceProvider are FutureProviders that must be awaited with .future to ensure preferences are loaded before use.
-- `lib/providers/library_provider.dart` — Riverpod providers for platforms and display settings. Search and display settings persistence. Does NOT handle game fetching — that is handled by paginated_games_provider.dart.
-- `lib/providers/paginated_games_provider.dart` — PaginatedGamesNotifier and PaginatedGamesState. Handles all game fetching with server-side pagination (50 per page). Per-key in-memory cache keyed by platformId+search. Methods: loadInitial(platformId, search), loadMore(), reset(). Platform filtering uses RomM 4.x platform_ids param.
-- `lib/providers/download_provider.dart` — Riverpod providers for active downloads (games and emulators) and progress.
+- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry, SaveSyncService.
+- `lib/providers/library_provider.dart` — Riverpod providers for platforms (with zero-game filtering) and display settings.
+- `lib/providers/paginated_games_provider.dart` — PaginatedGamesNotifier. Handles all game fetching with server-side pagination.
+- `lib/providers/download_provider.dart` — Riverpod providers for active downloads.
 
 ### UI — Screens
-- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, preset display system. Uses paginatedGamesProvider for all game display — scroll triggers loadMore(), platform/search changes trigger loadInitial(). Search is debounced 300ms. F5 and pull-to-refresh call reset() then loadInitial(). Launch flow shows status snackbars at each stage. Catches MissingRetroArchCoreException to offer auto-download of missing core then re-launches. Auto-syncs saves when game process exits via launchWithHandle().
-- `lib/ui/screens/library_skeleton.dart` — Skeleton loading grid and _SkeletonCard widget. Contains top-level functions buildSkeletonGrid() and calculateCardHeight().
-- `lib/ui/screens/download_screen.dart` — Active downloads list.
-- `lib/ui/screens/settings_screen.dart` — Server config, storage paths, RetroArch sync mode.
-- `lib/ui/screens/settings_display_section.dart` — Extracted display settings section. Contains buildDisplaySection() function with preset chips, column count, card shape, spacing, title and hover toggles.
-- `lib/ui/screens/settings_emulators_section.dart` — Extracted emulators section. Contains buildEmulatorsSection() and buildConflictsSection() functions. Handles emulator download, custom path overrides, and conflict resolution UI.
-
-### UI — Widgets
-- `lib/ui/widgets/game_card.dart` — StatefulWidget (not Consumer). Accepts pre-resolved coverUrl. Uses CachedNetworkImage (memCache: 300x400).
-- `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar.
-- `lib/ui/widgets/platform_filter_bar.dart` — Platform chip row.
-- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game overrides.
+- `lib/ui/screens/library_screen.dart` — Main screen. Game grid. Implements 3DS aes_keys.txt existence check before launch.
+- `lib/ui/screens/settings_screen.dart` — Server config and storage paths.
+- `lib/ui/screens/settings_emulators_section.dart` — Emulator management UI. Filtered by platform. Includes "Launch Standalone", "Update" (blue), and "Uninstall" (red) buttons.
 
 ## Key Contracts
 
-### EmulatorStrategy (never change these signatures)
+### EmulatorStrategy
 ```dart
 abstract class EmulatorStrategy {
   String get name;
   String get emulatorId;
   List<String> get supportedSlugs;
-  String get windowsExecutable;
-  String get linuxExecutable;
   String getExecutableForPlatform();
   Future<void> launch(Game game, String romPath);
+  Future<void> launchStandalone();
   String resolveSavePath(Game game);
   bool get supportsSaveSync;
 }

-### DownloadProgress -```dart -class DownloadProgress {

  • final String id; // e.g., game ID or emulator ID
  • final String gameName; // display name (game.name or emulator name)
  • final double percent;
  • final int bytesReceived;
  • final int totalBytes;
  • final bool isComplete;
  • final String? error; -} -```

-### RomMConfig -```dart -class RomMConfig {

  • final String baseUrl;
  • final String username;
  • final String password; -} -```

Dependencies (pubspec.yaml)

  • flutter_riverpod — state management -- dio — HTTP client for API calls and downloads +- dio — HTTP client
  • path_provider — platform-safe file paths -- shared_preferences — persist RomM config, display settings, overrides -- package_info_plus — read current app version -- archive — zip extraction and creation -- cached_network_image — disk and memory cache for network images -- file_picker — directory and file selection -- path — path manipulation utilities -- thirdparty/7zr.exe — bundled 7-Zip console executable for .7z extraction (Flutter asset) \ No newline at end of file +- shared_preferences — persistence +- archive — zip utilities +- cached_network_image — image caching +- file_picker — file selection +- path — path manipulation +- thirdparty/7zr.exe — bundled 7-Zip (Windows) +- thirdparty/7zz — bundled 7-Zip (macOS) diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0'

+# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', {

  • 'Debug' => :debug,
  • 'Profile' => :release,
  • 'Release' => :release, +}

+def flutter_root

  • generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), FILE)
  • unless File.exist?(generated_xcode_build_settings_path)
  • raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  • end
  • File.foreach(generated_xcode_build_settings_path) do |line|
  • matches = line.match(/FLUTTER_ROOT=(.*)/)
  • return matches[1].strip if matches
  • end
  • raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end

+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do

  • use_frameworks!
  • flutter_install_all_ios_pods File.dirname(File.realpath(FILE))
  • target 'RunnerTests' do
  • inherit! :search_paths
  • end +end

+post_install do |installer|

  • installer.pods_project.targets.each do |target|
  • flutter_additional_ios_build_settings(target)
  • end +end diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart index ee7cce4..ee7ba41 100644 --- a/lib/core/downloader/download_service.dart +++ b/lib/core/downloader/download_service.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:freegosy/core/storage/directory_service.dart'; import 'package:freegosy/core/extraction/extraction_service.dart'; import 'package:freegosy/core/romm/romm_models.dart'; @@ -69,10 +70,44 @@ class DownloadService { deleteOnError: true, ).then((_) async { try {
  •      File file = File(savePath);
    
  •      String currentPath = savePath;
    
  •      debugPrint("=== DOWNLOAD COMPLETION DEBUG ===");
    
  •      debugPrint("Game: ${game.name}, hasMultipleFiles: ${game.isMultiFile}");
    
  •      debugPrint("Downloaded File Path: $savePath");
    
  •      bool isZipSignature = false;
    
  •      if (await file.exists()) {
    
  •        // Peek at first 4 bytes for ZIP signature [0x50, 0x4B, 0x03, 0x04]
    
  •        final raf = await file.open();
    
  •        final bytes = await raf.read(4);
    
  •        await raf.close();
    
  •        debugPrint("File Magic Bytes: $bytes");
    
  •        isZipSignature = bytes.length == 4 &&
    
  •            bytes[0] == 0x50 &&
    
  •            bytes[1] == 0x4B &&
    
  •            bytes[2] == 0x03 &&
    
  •            bytes[3] == 0x04;
    
  •        debugPrint("Forcing ZIP extraction: $isZipSignature");
    
  •        if (isZipSignature) {
    
  •          if (!currentPath.toLowerCase().endsWith('.zip')) {
    
  •            final newPath = '$currentPath.zip';
    
  •            await file.rename(newPath);
    
  •            currentPath = newPath;
    
  •          }
    
  •        }
    
  •      }
    
  •      final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
    
  •      final isArchive = savePath.toLowerCase().endsWith('.zip') ||
    
  •          savePath.toLowerCase().endsWith('.7z');
    
  •      final shouldExtract = game.isMultiFile || (isWindowsGame && isArchive);
    
  •      final isArchive = currentPath.toLowerCase().endsWith('.zip') ||
    
  •          currentPath.toLowerCase().endsWith('.7z');
    
  •      final shouldExtract = game.isMultiFile || (isWindowsGame && isArchive) || isZipSignature;
    
  •      if (shouldExtract) {
           controller.add(DownloadProgress(
             id: game.id,
    

@@ -80,7 +115,7 @@ class DownloadService { percent: 1.0, status: 'Extracting...', ));

  •        await _extractMultiFile(game, savePath);
    
  •        await _extractMultiFile(game, currentPath);
         }
         controller.add(DownloadProgress(
           id: game.id,
    

diff --git a/lib/core/emulator/emulator_download_service.dart b/lib/core/emulator/emulator_download_service.dart index 968a204..9a4847b 100644 --- a/lib/core/emulator/emulator_download_service.dart +++ b/lib/core/emulator/emulator_download_service.dart @@ -45,9 +45,42 @@ class EmulatorDownloadService { gameName: emulatorName, status: 'Fetching latest release...', );

  •  final repo = definition['github_repo'] as String;
    
  •  final required = List<String>.from(definition['github_asset_required'] ?? []);
    
  •  final excluded = List<String>.from(definition['github_asset_excluded'] ?? []);
    
  •  String repo = definition['github_repo'] as String;
    
  •  if (Platform.isWindows && definition.containsKey('github_repo_windows')) {
    
  •    repo = definition['github_repo_windows'] as String;
    
  •  } else if (Platform.isMacOS && definition.containsKey('github_repo_macos')) {
    
  •    repo = definition['github_repo_macos'] as String;
    
  •  } else if (Platform.isLinux && definition.containsKey('github_repo_linux')) {
    
  •    repo = definition['github_repo_linux'] as String;
    
  •  }
    
  •  // Determine platform-specific asset filters with generic fallbacks
    
  •  final String requiredKey;
    
  •  final String excludedKey;
    
  •  if (Platform.isWindows && definition.containsKey('github_asset_required_windows')) {
    
  •    requiredKey = 'github_asset_required_windows';
    
  •  } else if (Platform.isMacOS && definition.containsKey('github_asset_required_macos')) {
    
  •    requiredKey = 'github_asset_required_macos';
    
  •  } else if (Platform.isLinux && definition.containsKey('github_asset_required_linux')) {
    
  •    requiredKey = 'github_asset_required_linux';
    
  •  } else {
    
  •    requiredKey = 'github_asset_required';
    
  •  }
    
  •  if (Platform.isWindows && definition.containsKey('github_asset_excluded_windows')) {
    
  •    excludedKey = 'github_asset_excluded_windows';
    
  •  } else if (Platform.isMacOS && definition.containsKey('github_asset_excluded_macos')) {
    
  •    excludedKey = 'github_asset_excluded_macos';
    
  •  } else if (Platform.isLinux && definition.containsKey('github_asset_excluded_linux')) {
    
  •    excludedKey = 'github_asset_excluded_linux';
    
  •  } else {
    
  •    excludedKey = 'github_asset_excluded';
    
  •  }
    
  •  final required = List<String>.from(definition[requiredKey] ?? []);
    
  •  final excluded = List<String>.from(definition[excludedKey] ?? []);
     downloadUrl = await _githubService.getLatestReleaseUrl(
       repo: repo,
       required: required,
    

@@ -62,9 +95,13 @@ class EmulatorDownloadService { return; } } else {

  •  downloadUrl = Platform.isWindows
    
  •      ? definition['windows_url'] as String?
    
  •      : definition['linux_url'] as String?;
    
  •  if (Platform.isWindows) {
    
  •    downloadUrl = definition['windows_url'] as String?;
    
  •  } else if (Platform.isMacOS) {
    
  •    downloadUrl = definition['macos_url'] as String?;
    
  •  } else {
    
  •    downloadUrl = definition['linux_url'] as String?;
    
  •  }
    

    }

    if (downloadUrl == null) { diff --git a/lib/core/emulator/emulator_registry_data.dart b/lib/core/emulator/emulator_registry_data.dart index 54ba544..112a55b 100644 --- a/lib/core/emulator/emulator_registry_data.dart +++ b/lib/core/emulator/emulator_registry_data.dart @@ -3,16 +3,19 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'id': 'retroarch', 'name': 'RetroArch', 'type': 'direct',

  •  'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
    
  •  'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos', '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'
    
    ],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'dolphin', @@ -21,8 +24,10 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_url': 'https://dl.dolphin-emu.org/builds/dolphin-master-latest-x64.7z', 'windows_executable': 'Dolphin.exe', 'linux_executable': 'dolphin-emu',
  • 'macos_url': 'https://dl.dolphin-emu.org/releases/2603a/dolphin-2603a-universal.dmg',
  • 'macos_executable': 'Dolphin.app/Contents/MacOS/Dolphin', 'platform_slugs': ['gc', 'gamecube', 'wii', 'ngc'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'eden', @@ -31,20 +36,25 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'windows_url': 'https://git.eden-emu.dev/eden-emu/eden/-/releases/permalink/latest/downloads/eden-windows-msvc-install.zip', 'windows_executable': 'eden.exe', 'linux_executable': 'eden',
  • 'macos_url': 'https://git.eden-emu.dev/eden-emu/eden/releases/download/v0.2.0-rc2/Eden-macOS-v0.2.0-rc2.tar.gz',
  • 'macos_executable': 'Eden.app/Contents/MacOS/Eden', 'platform_slugs': ['switch', 'nintendo-switch', 'ns'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'rpcs3', 'name': 'RPCS3 (PlayStation 3)', 'type': 'github', 'github_repo': 'RPCS3/rpcs3',
  • 'github_repo_macos': 'RPCS3/rpcs3-binaries-mac-arm64', 'github_asset_required': ['win64'],
  • 'github_asset_required_macos': ['macos', 'aarch64', '.7z'], 'github_asset_excluded': ['debug'], 'windows_executable': 'rpcs3.exe', 'linux_executable': 'rpcs3',
  • 'macos_executable': 'RPCS3.app/Contents/MacOS/RPCS3', 'platform_slugs': ['ps3', 'playstation-3', 'playstation3'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'pcsx2', @@ -52,23 +62,29 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'PCSX2/pcsx2', 'github_asset_required': ['windows', 'Qt'],
  • 'github_asset_excluded': ['debug', 'sse4', 'arm64'],
  • 'github_asset_required_macos': ['macos', 'Qt'],
  • 'github_asset_excluded': ['debug', 'sse4'],
  • 'github_asset_excluded_windows': ['arm64'], 'windows_executable': 'pcsx2-qt.exe', 'linux_executable': 'pcsx2-qt',
  • 'macos_executable': 'PCSX2.app/Contents/MacOS/PCSX2', 'platform_slugs': ['ps2', 'playstation-2', 'playstation2'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'azahar', 'name': 'Azahar (Nintendo 3DS)', 'type': 'github', 'github_repo': 'azahar-emu/azahar',
  • 'github_asset_required': ['windows', 'msys2'],
  • 'github_asset_excluded': ['debug', 'appimage'],
  • 'github_asset_required_windows': ['windows', 'msys2'],
  • 'github_asset_required_macos': ['macos', 'arm64'],
  • 'github_asset_excluded_windows': ['debug', 'appimage'],
  • 'github_asset_excluded_macos': ['debug', 'libretro'], 'windows_executable': 'azahar.exe', 'linux_executable': 'azahar',
  • 'macos_executable': 'Azahar.app/Contents/MacOS/Azahar', 'platform_slugs': ['3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'cemu', @@ -76,11 +92,15 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'cemu-project/Cemu', 'github_asset_required': ['windows', 'x64', '.zip'],
  • 'github_asset_excluded': ['debug', 'symbols', 'linux', 'macos'],
  • 'github_asset_required_macos': ['macos', '.dmg'],
  • 'github_asset_excluded': ['debug', 'symbols'],
  • 'github_asset_excluded_windows': ['linux', 'macos'],
  • 'github_asset_excluded_macos': ['windows', 'linux'], 'windows_executable': 'Cemu.exe', 'linux_executable': 'cemu',
  • 'macos_executable': 'Cemu.app/Contents/MacOS/Cemu', 'platform_slugs': ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'xemu', @@ -88,11 +108,15 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'xemu-project/xemu', 'github_asset_required': ['win', 'x86_64', 'release'],
  • 'github_asset_excluded': ['dbg', 'pdb', 'arm', 'macos', 'appimage', 'tar'],
  • 'github_asset_required_macos': ['macos', 'release'],
  • 'github_asset_excluded': ['dbg', 'pdb', 'arm', 'appimage', 'tar'],
  • 'github_asset_excluded_windows': ['macos'],
  • 'github_asset_excluded_macos': ['win'], 'windows_executable': 'xemu.exe', 'linux_executable': 'xemu',
  • 'macos_executable': 'Xemu.app/Contents/MacOS/xemu', 'platform_slugs': ['xbox'],
  • 'supported_platforms': ['windows'],
  • 'supported_platforms': ['windows', 'macos'], }, { 'id': 'xenia_canary', @@ -112,11 +136,13 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'stenzek/duckstation', 'github_asset_required': ['windows', 'x64'],
  • 'github_asset_required_macos': ['mac', 'release'], 'github_asset_excluded': ['nogui', 'debug', 'arm64'], 'windows_executable': 'duckstation-qt-x64-ReleaseLTCG.exe', 'linux_executable': 'duckstation-qt',
  • 'macos_executable': 'DuckStation.app/Contents/MacOS/DuckStation', 'platform_slugs': ['ps1', 'playstation', 'psx'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'flycast', @@ -124,11 +150,15 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'flyinghead/flycast', 'github_asset_required': ['win64', '.zip'],
  • 'github_asset_excluded': ['debug', 'symbols', 'linux', 'macos'],
  • 'github_asset_required_macos': ['macOS', '.zip'],
  • 'github_asset_excluded': ['debug', 'symbols'],
  • 'github_asset_excluded_windows': ['linux', 'macos'],
  • 'github_asset_excluded_macos': ['windows', 'linux'], 'windows_executable': 'flycast.exe', 'linux_executable': 'flycast',
  • 'macos_executable': 'Flycast.app/Contents/MacOS/Flycast', 'platform_slugs': ['dc', 'dreamcast', 'naomi', 'naomi2', 'atomiswave', 'cave', 'hikaru'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'melonds', @@ -136,22 +166,27 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'melonDS-emu/melonDS', 'github_asset_required': ['win', '.zip'],
  • 'github_asset_required_macos': ['macos', '.zip'], 'github_asset_excluded': ['source', 'debug'],
  • 'github_asset_excluded_macos': ['libretro'], 'windows_executable': 'melonDS.exe', 'linux_executable': 'melonDS',
  • 'macos_executable': 'melonDS.app/Contents/MacOS/melonDS', 'platform_slugs': ['nds', 'nintendo-ds', 'ds'],
  • 'supported_platforms': ['windows', 'linux'],
  • 'supported_platforms': ['windows', 'linux', 'macos'], }, { 'id': 'ppsspp', 'name': 'PPSSPP (PSP)',
  • 'type': 'github',
  • 'type': 'direct', 'github_repo': 'hrydgard/ppsspp', 'github_asset_required': ['Windows', 'x64', '.zip'], 'github_asset_excluded': ['debug', 'symbols', 'VR'], 'windows_executable': 'PPSSPPWindows64.exe', 'linux_executable': 'PPSSPP',
  • 'supported_platforms': ['windows', 'linux'],
  • 'macos_url': 'https://github.com/hrydgard/ppsspp/releases/download/v1.20.3/PPSSPPSDL-macOS-v1.20.3.zip',
  • 'macos_executable': 'PPSSPPSDL.app/Contents/MacOS/PPSSPPSDL',
  • 'supported_platforms': ['windows', 'linux', 'macos'], 'platform_slugs': ['psp', 'playstation-portable'], }, { @@ -160,10 +195,12 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ 'type': 'github', 'github_repo': 'mgba-emu/mgba', 'github_asset_required': ['win64', '.7z'],
  • 'github_asset_required_macos': ['macos', '.dmg'], 'github_asset_excluded': ['debug', 'symbols', 'source'], 'windows_executable': 'mGBA.exe', 'linux_executable': 'mgba',
  • 'supported_platforms': ['windows', 'linux'],
  • 'macos_executable': 'mGBA.app/Contents/MacOS/mGBA',

  • 'supported_platforms': ['windows', 'linux', 'macos'], 'platform_slugs': ['gba', 'gbc', 'gb', 'game-boy-advance', 'game-boy-color', 'game-boy'], }, { diff --git a/lib/core/emulator/emulator_strategy.dart b/lib/core/emulator/emulator_strategy.dart index b0e70f9..8ad4e0f 100644 --- a/lib/core/emulator/emulator_strategy.dart +++ b/lib/core/emulator/emulator_strategy.dart @@ -8,15 +8,18 @@ abstract class EmulatorStrategy { List get supportedSlugs; String get windowsExecutable; String get linuxExecutable;

  • String get macosExecutable => windowsExecutable; bool get supportsSaveSync;

    String getExecutableForPlatform() { if (io.Platform.isWindows) return windowsExecutable; if (io.Platform.isLinux) return linuxExecutable;

  • if (io.Platform.isMacOS) return macosExecutable; return windowsExecutable; }

Future launch(Game game, String romPath); Future<Process?> launchWithHandle(Game game, String romPath) async => null;

  • Future launchStandalone(); String resolveSavePath(Game game); } diff --git a/lib/core/emulator/strategies/azahar_strategy.dart b/lib/core/emulator/strategies/azahar_strategy.dart index 2322d9f..638fe05 100644 --- a/lib/core/emulator/strategies/azahar_strategy.dart +++ b/lib/core/emulator/strategies/azahar_strategy.dart @@ -1,4 +1,7 @@ import 'dart:io'; +import 'dart:io' as io; +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -27,14 +30,64 @@ class AzaharStrategy extends EmulatorStrategy { String get linuxExecutable => 'azahar';

    @override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'Azahar.app/Contents/MacOS/azahar';

  • @override

  • bool get supportsSaveSync => true;

  • Future _ensure3dsFonts(String azaharSystemDir) async {

  • final fontFile = io.File(p.join(azaharSystemDir, 'sysdata', 'shared_font.bin'));

  • if (await fontFile.exists()) return;

  • final dio = Dio();

  • try {

  •  await dio.download(
    
  •    'https://github.com/citra-emu/citra-sysdata-mks/raw/master/shared_font.bin',
    
  •    fontFile.path,
    
  •  );
    
  • } catch (e) {

  •  // ignore
    
  • }

  • }

  • Future _ensure3dsSetup() async {

  • final azaharSystemDir = await _directoryService.getEmulatorSystemDirectory(emulatorId);

  • final sysdataDir = io.Directory(p.join(azaharSystemDir, 'sysdata'));

  • final configDir = io.Directory(p.join(azaharSystemDir, 'config'));

  • if (!await sysdataDir.exists()) await sysdataDir.create(recursive: true);

  • if (!await configDir.exists()) await configDir.create(recursive: true);

  • await _ensure3dsFonts(azaharSystemDir);

  • }

    @override Future launch(Game game, String romPath) async { final exePath = await _directoryService.findEmulatorExecutable( emulatorId, getExecutableForPlatform(), );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  if (exePath.endsWith('.dylib')) {
    
  •    throw Exception('Found Azahar Libretro core. Please switch to RetroArch in Settings to play this game.');
    
  •  }
    
  •  // On macOS, launching via 'open -a App.app --args rom' is much more stable than launching internal binary
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await _ensure3dsSetup();
    
  •      await io.Process.run('open', [appBundlePath, '--args', romPath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • await _ensure3dsSetup(); await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); }

@@ -43,10 +96,46 @@ class AzaharStrategy extends EmulatorStrategy { final exePath = await _directoryService.findEmulatorExecutable( emulatorId, getExecutableForPlatform(), ); + if (exePath == null) throw Exception('$name not found. Please download it first.'); +

  • if (io.Platform.isMacOS && exePath.endsWith('.dylib')) {

  •  throw Exception('Found Azahar Libretro core. Please switch to RetroArch in Settings to play this game.');
    
  • }

  • await _ensure3dsSetup(); return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await io.Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = io.File(exePath).parent.path;

  • await Process.start(

  •  exePath,
    
  •  [],
    
  •  mode: ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • );

  • }

  • @override String resolveSavePath(Game game) => ''; -} \ No newline at end of file +} diff --git a/lib/core/emulator/strategies/cemu_strategy.dart b/lib/core/emulator/strategies/cemu_strategy.dart index 326d2e4..338226f 100644 --- a/lib/core/emulator/strategies/cemu_strategy.dart +++ b/lib/core/emulator/strategies/cemu_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -24,7 +25,10 @@ class CemuStrategy extends EmulatorStrategy { String get linuxExecutable => 'cemu';

    @override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'Cemu.app/Contents/MacOS/Cemu';

  • @override

  • bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async { @@ -44,6 +48,35 @@ class CemuStrategy extends EmulatorStrategy { return await Process.start(exePath, ['-g', romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = File(exePath).parent.path;

  • await Process.start(

  •  exePath,
    
  •  [],
    
  •  mode: ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • );

  • }

  • @override String resolveSavePath(Game game) => ''; -} \ No newline at end of file +} diff --git a/lib/core/emulator/strategies/dolphin_strategy.dart b/lib/core/emulator/strategies/dolphin_strategy.dart index a1a8b40..8cb3155 100644 --- a/lib/core/emulator/strategies/dolphin_strategy.dart +++ b/lib/core/emulator/strategies/dolphin_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -23,21 +24,72 @@ class DolphinStrategy extends EmulatorStrategy { @override String get linuxExecutable => 'dolphin-emu';

  • @override

  • String get macosExecutable => 'Dolphin.app/Contents/MacOS/Dolphin';

  • @override bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async {

  • final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
  • if (exePath == null) throw Exception('$name not found. Please download it first.');
  • await Process.run(exePath, ['-e', romPath]);
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId,
    
  •  getExecutableForPlatform(),
    
  • );
  • if (exePath == null) {
  •  throw Exception('$name not found. Please download it first.');
    
  • }
  • await Process.start(
  •  exePath,
    
  •  ['-b', '-e', romPath],
    
  •  mode: ProcessStartMode.detached,
    
  • ); }

@override Future<Process?> launchWithHandle(Game game, String romPath) async {

  • final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId,
    
  •  getExecutableForPlatform(),
    
  • );
  • if (exePath == null) {
  •  throw Exception('$name not found. Please download it first.');
    
  • }
  • return await Process.start(
  •  exePath,
    
  •  ['-b', '-e', romPath],
    
  •  mode: ProcessStartMode.normal,
    
  • );
  • }
  • @override
  • Future launchStandalone() async {
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId, getExecutableForPlatform(),
    
  • ); if (exePath == null) throw Exception('$name not found. Please download it first.');
  • return await Process.start(exePath, ['-e', romPath], mode: ProcessStartMode.normal);
  • final exeDir = io.File(exePath).parent.path;
  • if (io.Platform.isMacOS) {
  •  // Find the .app bundle path (folder ending in .app containing the executable)
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }
  • await Process.start(
  •  exePath,
    
  •  [],
    
  •  mode: ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • ); }

@override diff --git a/lib/core/emulator/strategies/duckstation_strategy.dart b/lib/core/emulator/strategies/duckstation_strategy.dart index f098b2e..75b40e8 100644 --- a/lib/core/emulator/strategies/duckstation_strategy.dart +++ b/lib/core/emulator/strategies/duckstation_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -24,7 +25,10 @@ class DuckstationStrategy extends EmulatorStrategy { String get linuxExecutable => 'duckstation-qt';

@override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'DuckStation.app/Contents/MacOS/DuckStation';

  • @override

  • bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async { @@ -45,5 +49,42 @@ class DuckstationStrategy extends EmulatorStrategy { }

    @override

  • String resolveSavePath(Game game) => ''; -} \ No newline at end of file
  • Future launchStandalone() async {
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId, getExecutableForPlatform(),
    
  • );
  • if (exePath == null) throw Exception('$name not found. Please download it first.');
  • if (io.Platform.isMacOS) {
  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }
  • final exeDir = File(exePath).parent.path;
  • await Process.start(
  •  exePath,
    
  •  [],
    
  •  mode: ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • );
  • }
  • @override
  • String resolveSavePath(Game game) {
  • if (io.Platform.isMacOS) {
  •  final home = io.Platform.environment['HOME'];
    
  •  if (home != null) {
    
  •    return '$home/Library/Application Support/DuckStation/';
    
  •  }
    
  • }
  • return '';
  • } +} diff --git a/lib/core/emulator/strategies/eden_strategy.dart b/lib/core/emulator/strategies/eden_strategy.dart index d72d953..bc146e6 100644 --- a/lib/core/emulator/strategies/eden_strategy.dart +++ b/lib/core/emulator/strategies/eden_strategy.dart @@ -1,7 +1,7 @@ -import 'dart:io'; -import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'dart:io' as io; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; +import '../emulator_strategy.dart';

class EdenStrategy extends EmulatorStrategy { final DirectoryService _directoryService; @@ -9,7 +9,7 @@ class EdenStrategy extends EmulatorStrategy { EdenStrategy(this._directoryService);

@override

  • String get name => 'Eden (Switch)';
  • String get name => 'Eden';

    @override String get emulatorId => 'eden'; @@ -24,24 +24,102 @@ class EdenStrategy extends EmulatorStrategy { String get linuxExecutable => 'eden';

    @override

  • bool get supportsSaveSync => true;
  • String get macosExecutable => 'Eden.app/Contents/MacOS/Eden';

  • @override

  • bool get supportsSaveSync => false;

    @override Future launch(Game game, String romPath) async {

  • final resolvedPath = _resolveRomPath(romPath); final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());

  • if (exePath == null) throw Exception('$name not found. Please download it first.');
  • await Process.run(exePath, [romPath]);
  • if (exePath == null) return;
  • String? workingDir;
  • if (io.Platform.isMacOS) {
  •  workingDir = io.File(exePath).parent.path;
    
  • }
  • final args = [resolvedPath];
  • await io.Process.start(
  •  exePath,
    
  •  args,
    
  •  mode: io.ProcessStartMode.detached,
    
  •  workingDirectory: workingDir,
    
  • ); }

@override

  • Future<Process?> launchWithHandle(Game game, String romPath) async {
  • Future<io.Process?> launchWithHandle(Game game, String romPath) async {
  • final resolvedPath = _resolveRomPath(romPath); final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
  • if (exePath == null) return null;
  • String? workingDir;
  • if (io.Platform.isMacOS) {
  •  workingDir = io.File(exePath).parent.path;
    
  • }
  • final args = [resolvedPath];
  • return await io.Process.start(
  •  exePath,
    
  •  args,
    
  •  mode: io.ProcessStartMode.normal,
    
  •  workingDirectory: workingDir,
    
  • );
  • }
  • String _resolveRomPath(String romPath) {
  • if (io.FileSystemEntity.isDirectorySync(romPath)) {
  •  final dir = io.Directory(romPath);
    
  •  final files = dir.listSync(recursive: true).whereType<io.File>().where((file) {
    
  •    final ext = file.path.toLowerCase();
    
  •    return ext.endsWith('.nsp') || ext.endsWith('.xci') || ext.endsWith('.nsz');
    
  •  }).toList();
    
  •  if (files.isEmpty) {
    
  •    throw Exception("No valid Switch ROM (.nsp/.xci/.nsz) found in directory.");
    
  •  }
    
  •  // Sort by file size descending and pick the largest one
    
  •  files.sort((a, b) => b.lengthSync().compareTo(a.lengthSync()));
    
  •  return files.first.path;
    
  • }
  • return romPath;
  • }
  • @override
  • Future launchStandalone() async {
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId, getExecutableForPlatform(),
    
  • ); if (exePath == null) throw Exception('$name not found. Please download it first.');
  • return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
  • final exeDir = io.File(exePath).parent.path;
  • if (io.Platform.isMacOS) {
  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await io.Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }
  • await io.Process.start(
  •  exePath,
    
  •  [],
    
  •  mode: io.ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • ); }

@override String resolveSavePath(Game game) {

  • return ''; // Placeholder
  • return ""; } } diff --git a/lib/core/emulator/strategies/flycast_strategy.dart b/lib/core/emulator/strategies/flycast_strategy.dart index 6606830..1d897f4 100644 --- a/lib/core/emulator/strategies/flycast_strategy.dart +++ b/lib/core/emulator/strategies/flycast_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -15,7 +16,7 @@ class FlycastStrategy extends EmulatorStrategy { String get emulatorId => 'flycast';

@override

  • List get supportedSlugs => ['dc', 'dreamcast', 'naomi', 'atomiswave'];
  • List get supportedSlugs => ['dc', 'dreamcast', 'naomi', 'naomi2', 'atomiswave', 'cave', 'hikaru'];

    @override String get windowsExecutable => 'flycast.exe'; @@ -23,13 +24,17 @@ class FlycastStrategy extends EmulatorStrategy { @override String get linuxExecutable => 'flycast';

  • @override

  • String get macosExecutable => 'Flycast.app/Contents/MacOS/Flycast';

  • @override bool get supportsSaveSync => false;

    @override Future launch(Game game, String romPath) async { final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  •  emulatorId,
    
  •  getExecutableForPlatform(),
    
    ); if (exePath == null) throw Exception('$name not found. Please download it first.'); await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); @@ -38,12 +43,42 @@ class FlycastStrategy extends EmulatorStrategy { @override Future<Process?> launchWithHandle(Game game, String romPath) async { final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId, getExecutableForPlatform(),
    
  •  emulatorId,
    
  •  getExecutableForPlatform(),
    

    ); if (exePath == null) throw Exception('$name not found. Please download it first.'); return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = File(exePath).parent.path;

  • await Process.start(

  •  exePath,
    
  •  [],
    
  •  mode: ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • );

  • }

  • @override String resolveSavePath(Game game) => ''; } diff --git a/lib/core/emulator/strategies/mame_strategy.dart b/lib/core/emulator/strategies/mame_strategy.dart index 9847c9c..70c66c6 100644 --- a/lib/core/emulator/strategies/mame_strategy.dart +++ b/lib/core/emulator/strategies/mame_strategy.dart @@ -64,6 +64,22 @@ class MAMEStrategy extends EmulatorStrategy { await Process.start(mameExePath, [romPath], mode: ProcessStartMode.detached); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • final exeDir = File(exePath).parent.path;

  • await Process.start(

  •  exePath,
    
  •  [],
    
  •  mode: ProcessStartMode.detached,
    
  •  workingDirectory: exeDir,
    
  • );

  • }

  • @override String resolveSavePath(Game game) => ''; } diff --git a/lib/core/emulator/strategies/melonds_strategy.dart b/lib/core/emulator/strategies/melonds_strategy.dart index 345745f..4ab8d1d 100644 --- a/lib/core/emulator/strategies/melonds_strategy.dart +++ b/lib/core/emulator/strategies/melonds_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -24,19 +25,27 @@ class MelonDSStrategy extends EmulatorStrategy { String get linuxExecutable => 'melonDS';

    @override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'melonDS.app/Contents/MacOS/melonDS';

  • @override

  • bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async {

  • final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId,
    
  •  getExecutableForPlatform(),
    
  • ); if (exePath == null) throw Exception('$name not found. Please download it first.');
  • final workingDir = File(exePath).parent.path;
  • await Process.start(exePath, [romPath], workingDirectory: workingDir, mode: ProcessStartMode.detached);
  • await Process.start(exePath, [romPath], mode: ProcessStartMode.detached); }

@override Future<Process?> launchWithHandle(Game game, String romPath) async {

  • final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId,
    
  •  getExecutableForPlatform(),
    
  • ); if (exePath == null) throw Exception('$name not found. Please download it first.'); final workingDir = File(exePath).parent.path; final process = await Process.start(exePath, [romPath], workingDirectory: workingDir, mode: ProcessStartMode.normal); @@ -45,6 +54,30 @@ class MelonDSStrategy extends EmulatorStrategy { return process; }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = File(exePath).parent.path;

  • await Process.start(exePath, [], workingDirectory: exeDir, mode: ProcessStartMode.detached);

  • }

  • @override String resolveSavePath(Game game) { return ''; // Placeholder diff --git a/lib/core/emulator/strategies/mgba_strategy.dart b/lib/core/emulator/strategies/mgba_strategy.dart index 8aee232..e8e9f54 100644 --- a/lib/core/emulator/strategies/mgba_strategy.dart +++ b/lib/core/emulator/strategies/mgba_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -23,6 +24,9 @@ class MGBAStrategy extends EmulatorStrategy { @override String get linuxExecutable => 'mgba';

  • @override

  • String get macosExecutable => 'mGBA.app/Contents/MacOS/mGBA';

  • @override bool get supportsSaveSync => false;

@@ -44,6 +48,30 @@ class MGBAStrategy extends EmulatorStrategy { return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = File(exePath).parent.path;

  • await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir);

  • }

  • @override String resolveSavePath(Game game) => ''; } diff --git a/lib/core/emulator/strategies/pcsx2_strategy.dart b/lib/core/emulator/strategies/pcsx2_strategy.dart index 8fd512e..4004312 100644 --- a/lib/core/emulator/strategies/pcsx2_strategy.dart +++ b/lib/core/emulator/strategies/pcsx2_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -24,7 +25,10 @@ class Pcsx2Strategy extends EmulatorStrategy { String get linuxExecutable => 'pcsx2-qt';

    @override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'PCSX2.app/Contents/MacOS/PCSX2';

  • @override

  • bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async { @@ -32,9 +36,14 @@ class Pcsx2Strategy extends EmulatorStrategy { emulatorId, getExecutableForPlatform(), ); if (exePath == null) throw Exception('$name not found. Please download it first.');

  • final normalizedRom = romPath.replaceAll('/', '\');
  • final normalizedExe = exePath.replaceAll('/', '\');
  • await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.detached);
  • if (io.Platform.isWindows) {
  •  final normalizedRom = romPath.replaceAll('/', '\\');
    
  •  final normalizedExe = exePath.replaceAll('/', '\\');
    
  •  await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.detached);
    
  • } else {
  •  await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
    
  • } }

@override @@ -43,11 +52,55 @@ class Pcsx2Strategy extends EmulatorStrategy { emulatorId, getExecutableForPlatform(), ); if (exePath == null) throw Exception('$name not found. Please download it first.');

  • final normalizedRom = romPath.replaceAll('/', '\');
  • final normalizedExe = exePath.replaceAll('/', '\');
  • return await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.normal);
  • if (io.Platform.isWindows) {
  •  final normalizedRom = romPath.replaceAll('/', '\\');
    
  •  final normalizedExe = exePath.replaceAll('/', '\\');
    
  •  return await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.normal);
    
  • } else {
  •  return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
    
  • } }

@override

  • String resolveSavePath(Game game) => ''; -} \ No newline at end of file
  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = File(exePath).parent.path;

  • if (io.Platform.isWindows) {

  •  final normalizedExe = exePath.replaceAll('/', '\\');
    
  •  final normalizedDir = exeDir.replaceAll('/', '\\');
    
  •  await Process.start(normalizedExe, [], mode: ProcessStartMode.detached, workingDirectory: normalizedDir);
    
  • } else {

  •  await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir);
    
  • }

  • }

  • @override

  • String resolveSavePath(Game game) {

  • if (io.Platform.isMacOS) {

  •  final home = io.Platform.environment['HOME'];
    
  •  if (home != null) {
    
  •    return '$home/Library/Application Support/PCSX2/';
    
  •  }
    
  • }

  • return '';

  • } +} diff --git a/lib/core/emulator/strategies/ppsspp_strategy.dart b/lib/core/emulator/strategies/ppsspp_strategy.dart index 394f752..03be81c 100644 --- a/lib/core/emulator/strategies/ppsspp_strategy.dart +++ b/lib/core/emulator/strategies/ppsspp_strategy.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -24,7 +25,10 @@ class PPSSPPStrategy extends EmulatorStrategy { String get linuxExecutable => 'PPSSPP';

    @override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'PPSSPPSDL.app/Contents/MacOS/PPSSPPSDL';

  • @override

  • bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async { @@ -44,6 +48,29 @@ class PPSSPPStrategy extends EmulatorStrategy { return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = File(exePath).parent.path;

  • await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir);

  • }

  • @override String resolveSavePath(Game game) => ''; } diff --git a/lib/core/emulator/strategies/retroarch_strategy.dart b/lib/core/emulator/strategies/retroarch_strategy.dart index de607f5..c3d7155 100644 --- a/lib/core/emulator/strategies/retroarch_strategy.dart +++ b/lib/core/emulator/strategies/retroarch_strategy.dart @@ -1,4 +1,5 @@ -import 'dart:io'; +import 'dart:io' as io show Platform, File, Directory; +import 'dart:io' show Process, ProcessStartMode; import 'package:dio/dio.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -42,7 +43,8 @@ class RetroArchStrategy extends EmulatorStrategy { 'dc', 'dreamcast', 'megadrive', 'genesis', 'md', 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo',

  •  'arcade', 'mame', 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
    
  •  'arcade', 'mame', 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos',
    
  •  '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'
    

    ];

    @override @@ -51,15 +53,23 @@ class RetroArchStrategy extends EmulatorStrategy { @override String get linuxExecutable => 'retroarch';

  • @override

  • String get macosExecutable => 'RetroArch.app/Contents/MacOS/RetroArch';

  • @override bool get supportsSaveSync => true;

  • static const Map<String, String> _coreMap = {
  • static const Map<String, String> _coreMapWindows = { // Nintendo handhelds 'gba': 'mgba_libretro.dll', 'gbc': 'mgba_libretro.dll', 'gb': 'mgba_libretro.dll', 'nds': 'desmume2015_libretro.dll',

  • '3ds': 'azahar_libretro.dll',

  • 'n3ds': 'azahar_libretro.dll',

  • 'nintendo-3ds':'azahar_libretro.dll',

  • 'new-nintendo-3ds': 'azahar_libretro.dll',

  • 'new-nintendo-3ds-xl': 'azahar_libretro.dll', 'virtualboy': 'mednafen_vb_libretro.dll', // Nintendo home 'nes': 'fceumm_libretro.dll', @@ -96,9 +106,108 @@ class RetroArchStrategy extends EmulatorStrategy { 'dos': 'dosbox_pure_libretro.dll', };

  • static const Map<String, String> _coreMapUnix = {

  • // Nintendo handhelds

  • 'gba': 'mgba_libretro.dylib',

  • 'gbc': 'mgba_libretro.dylib',

  • 'gb': 'mgba_libretro.dylib',

  • 'nds': 'desmume2015_libretro.dylib',

  • '3ds': 'azahar_libretro.dylib',

  • 'n3ds': 'azahar_libretro.dylib',

  • 'nintendo-3ds':'azahar_libretro.dylib',

  • 'new-nintendo-3ds': 'azahar_libretro.dylib',

  • 'new-nintendo-3ds-xl': 'azahar_libretro.dylib',

  • 'virtualboy': 'mednafen_vb_libretro.dylib',

  • // Nintendo home

  • 'nes': 'fceumm_libretro.dylib',

  • 'snes': 'snes9x_libretro.dylib',

  • 'n64': 'mupen64plus_next_libretro.dylib',

  • // Sony

  • 'psx': 'pcsx_rearmed_libretro.dylib',

  • 'ps1': 'pcsx_rearmed_libretro.dylib',

  • 'playstation': 'pcsx_rearmed_libretro.dylib',

  • 'psp': 'ppsspp_libretro.dylib',

  • // Sega

  • 'megadrive': 'genesis_plus_gx_libretro.dylib',

  • 'genesis': 'genesis_plus_gx_libretro.dylib',

  • 'md': 'genesis_plus_gx_libretro.dylib',

  • 'segacd': 'genesis_plus_gx_libretro.dylib',

  • 'saturn': 'mednafen_saturn_libretro.dylib',

  • 'dc': 'flycast_libretro.dylib',

  • 'dreamcast': 'flycast_libretro.dylib',

  • 'gamegear': 'genesis_plus_gx_libretro.dylib',

  • // Atari

  • 'atari2600': 'stella_libretro.dylib',

  • 'atari7800': 'prosystem_libretro.dylib',

  • 'lynx': 'mednafen_lynx_libretro.dylib',

  • // Arcade / SNK

  • 'neogeo': 'fbneo_libretro.dylib',

  • 'arcade': 'fbneo_libretro.dylib',

  • 'mame': 'mame_libretro.dylib',

  • // NEC

  • 'pcengine': 'mednafen_pce_libretro.dylib',

  • // Bandai

  • 'wonderswan': 'mednafen_wswan_libretro.dylib',

  • // Other

  • 'msx': 'bluemsx_libretro.dylib',

  • 'dos': 'dosbox_pure_libretro.dylib',

  • };

  • String? _getCoreForSlug(String? slug) { if (slug == null) return null;

  • return _coreMap[slug.toLowerCase()];
  • final map = io.Platform.isWindows ? _coreMapWindows : _coreMapUnix;
  • return map[slug.toLowerCase()];
  • }
  • Future<String?> _resolveCorePath(String exePath, String coreName) async {
  • final sep = io.Platform.isWindows ? r'' : '/';
  • final exeDir = io.File(exePath).parent.path;
  • final standardPath = '$exeDir${sep}cores$sep$coreName';
  • if (await io.File(standardPath).exists()) {
  •  return standardPath;
    
  • }
  • // Try to find in standalone directory
  • // Extract emulatorId from coreName: 'azahar_libretro.dylib' -> 'azahar'
  • final underscoreIdx = coreName.indexOf('_');
  • if (underscoreIdx != -1) {
  •  final standaloneEmuId = coreName.substring(0, underscoreIdx);
    
  •  final foundPath = await _directoryService.findEmulatorExecutable(standaloneEmuId, coreName);
    
  •  if (foundPath != null) {
    
  •    return foundPath;
    
  •  }
    
  • }
  • return null;
  • }
  • Future _ensure3dsFonts(String citraSystemDir) async {
  • final fontFile = io.File(p.join(citraSystemDir, 'sysdata', 'shared_font.bin'));
  • if (await fontFile.exists()) return;
  • final dio = Dio();
  • try {
  •  await dio.download(
    
  •    'https://github.com/citra-emu/citra-sysdata-mks/raw/master/shared_font.bin',
    
  •    fontFile.path,
    
  •  );
    
  • } catch (e) {
  •  // ignore
    
  • }
  • }
  • Future _ensure3dsSetup() async {
  • final systemDir = await _directoryService.getEmulatorSystemDirectory('retroarch');
  • final citraDir = io.Directory(p.join(systemDir, 'citra'));
  • final sysdataDir = io.Directory(p.join(citraDir.path, 'sysdata'));
  • final configDir = io.Directory(p.join(citraDir.path, 'config'));
  • if (!await citraDir.exists()) await citraDir.create(recursive: true);
  • if (!await sysdataDir.exists()) await sysdataDir.create(recursive: true);
  • if (!await configDir.exists()) await configDir.create(recursive: true);
  • await _ensure3dsFonts(citraDir.path); }

@override @@ -109,33 +218,40 @@ class RetroArchStrategy extends EmulatorStrategy { throw Exception('$name not found. Please download it first.'); }

  • final normalizedExe = exePath.replaceAll('/', r'');
  • final normalizedRom = romPath.replaceAll('/', r'');
  • final normalizedRomPath = p.normalize(romPath); final coreName = _getCoreForSlug(game.platformSlug);

  • // 1. Check if platform is 3DS-related

  • final is3ds = [

  •  '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds',
    
  •  'new-nintendo-3ds', 'new-nintendo-3ds-xl'
    
  • ].contains(game.platformSlug?.toLowerCase());

  • if (is3ds) {

  •  // 2. Ensure 'citra' exists inside the RetroArch 'system' directory
    
  •  await _ensure3dsSetup();
    
  • }

  • if (coreName == null) {

  •  await Process.start(
    
  •    normalizedExe,
    
  •    [normalizedRom],
    
  •    mode: ProcessStartMode.detached,
    
  •  );
    
  •  await Process.start(exePath, [normalizedRomPath], mode: ProcessStartMode.detached);
     return;
    
    }
  • final exeDir = File(normalizedExe).parent.path;
  • final corePath = '$exeDir\cores\$coreName';
  • final corePath = await _resolveCorePath(exePath, coreName);
  • if (!await File(corePath).exists()) {
  • if (corePath == null) {
  •  final sep = io.Platform.isWindows ? r'\' : '/';
    
  •  final exeDir = io.File(exePath).parent.path;
     throw MissingRetroArchCoreException(
       coreName: coreName,
    
  •    corePath: corePath,
    
  •    exePath: normalizedExe,
    
  •    corePath: '$exeDir${sep}cores$sep$coreName',
    
  •    exePath: exePath,
     );
    

    }

    await Process.start(

  •  normalizedExe,
    
  •  ['-L', corePath, normalizedRom],
    
  •  exePath,
    
  •  ['-L', corePath, normalizedRomPath],
     mode: ProcessStartMode.detached,
    
    ); } @@ -148,60 +264,103 @@ class RetroArchStrategy extends EmulatorStrategy { throw Exception('$name not found. Please download it first.'); }
  • final normalizedExe = exePath.replaceAll('/', r'');
  • final normalizedRom = romPath.replaceAll('/', r'');
  • final normalizedRomPath = p.normalize(romPath); final coreName = _getCoreForSlug(game.platformSlug);

  • // 1. Check if platform is 3DS-related

  • final is3ds = [

  •  '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds',
    
  •  'new-nintendo-3ds', 'new-nintendo-3ds-xl'
    
  • ].contains(game.platformSlug?.toLowerCase());

  • if (is3ds) {

  •  // 2. Ensure 'citra' exists inside the RetroArch 'system' directory
    
  •  await _ensure3dsSetup();
    
  • }

  • if (coreName == null) {

  •  return await Process.start(
    
  •    normalizedExe,
    
  •    [normalizedRom],
    
  •    mode: ProcessStartMode.normal,
    
  •  );
    
  •  return await Process.start(exePath, [normalizedRomPath], mode: ProcessStartMode.normal);
    
    }
  • final exeDir = File(normalizedExe).parent.path;
  • final corePath = '$exeDir\cores\$coreName';
  • final corePath = await _resolveCorePath(exePath, coreName);
  • if (!await File(corePath).exists()) {
  • if (corePath == null) {
  •  final sep = io.Platform.isWindows ? r'\' : '/';
    
  •  final exeDir = io.File(exePath).parent.path;
     throw MissingRetroArchCoreException(
       coreName: coreName,
    
  •    corePath: corePath,
    
  •    exePath: normalizedExe,
    
  •    corePath: '$exeDir${sep}cores$sep$coreName',
    
  •    exePath: exePath,
     );
    

    }

    return await Process.start(

  •  normalizedExe,
    
  •  ['-L', corePath, normalizedRom],
    
  •  exePath,
    
  •  ['-L', corePath, normalizedRomPath],
     mode: ProcessStartMode.normal,
    

    ); }

    Future downloadCore(String coreName, String coresDir, Dio dio) async {

  • final String url;

  • final String ext;

  • if (io.Platform.isWindows) {

  •  ext = 'dll';
    
  •  url = 'https://buildbot.libretro.com/nightly/windows/x86_64/latest/$coreName.zip';
    
  • } else if (io.Platform.isMacOS) {

  •  ext = 'dylib';
    
  •  url = 'https://buildbot.libretro.com/nightly/apple/osx/arm64/latest/$coreName.zip';
    
  • } else {

  •  ext = 'so';
    
  •  url = 'https://buildbot.libretro.com/nightly/linux/x86_64/latest/$coreName.zip';
    
  • }

  • final tempDir = await getTemporaryDirectory(); final zipPath = p.join(tempDir.path, '$coreName.zip');

    try { await dio.download(url, zipPath);

  •  final bytes = await File(zipPath).readAsBytes();
    
  •  final bytes = await io.File(zipPath).readAsBytes();
     final archive = ZipDecoder().decodeBytes(bytes);
     for (final entry in archive) {
    
  •    if (entry.isFile && entry.name.endsWith('.dll')) {
    
  •      final outFile = File('$coresDir\\${entry.name}');
    
  •    if (entry.isFile && entry.name.endsWith('.$ext')) {
    
  •      final outFile = io.File(p.join(coresDir, entry.name));
         await outFile.parent.create(recursive: true);
         await outFile.writeAsBytes(entry.content as List<int>);
       }
     }
    
    } finally {
  •  final f = File(zipPath);
    
  •  final f = io.File(zipPath);
     if (await f.exists()) await f.delete();
    

    } }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • if (io.Platform.isMacOS) {

  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await io.Directory(appBundlePath).exists()) {
    
  •      await Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }

  • final exeDir = io.File(exePath).parent.path;

  • await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir);

  • }

  • @override String resolveSavePath(Game game) { return ''; } -} \ No newline at end of file +} diff --git a/lib/core/emulator/strategies/rpcs3_strategy.dart b/lib/core/emulator/strategies/rpcs3_strategy.dart index 1df09ca..99a4764 100644 --- a/lib/core/emulator/strategies/rpcs3_strategy.dart +++ b/lib/core/emulator/strategies/rpcs3_strategy.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'dart:io' as io; +import 'package:path/path.dart' as p; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -24,7 +26,10 @@ class Rpcs3Strategy extends EmulatorStrategy { String get linuxExecutable => 'rpcs3';

    @override

  • bool get supportsSaveSync => false;
  • String get macosExecutable => 'RPCS3.app/Contents/MacOS/RPCS3';

  • @override

  • bool get supportsSaveSync => true;

    @override Future launch(Game game, String romPath) async { @@ -32,7 +37,9 @@ class Rpcs3Strategy extends EmulatorStrategy { emulatorId, getExecutableForPlatform(), ); if (exePath == null) throw Exception('$name not found. Please download it first.');

  • await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
  • final normalizedRomPath = p.normalize(romPath);
  • await Process.start(exePath, [normalizedRomPath], mode: ProcessStartMode.detached); }

@override @@ -41,9 +48,35 @@ class Rpcs3Strategy extends EmulatorStrategy { emulatorId, getExecutableForPlatform(), ); if (exePath == null) throw Exception('$name not found. Please download it first.');

  • return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
  • final normalizedRomPath = p.normalize(romPath);
  • return await Process.start(exePath, [normalizedRomPath], mode: ProcessStartMode.normal);
  • }
  • @override
  • Future launchStandalone() async {
  • final exePath = await _directoryService.findEmulatorExecutable(
  •  emulatorId, getExecutableForPlatform(),
    
  • );
  • if (exePath == null) throw Exception('$name not found. Please download it first.');
  • if (io.Platform.isMacOS) {
  •  // Find the .app bundle path
    
  •  final parts = exePath.split('/');
    
  •  final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
    
  •  if (appIdx != -1) {
    
  •    final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
    
  •    if (await io.Directory(appBundlePath).exists()) {
    
  •      await io.Process.run('open', [appBundlePath]);
    
  •      return;
    
  •    }
    
  •  }
    
  • }
  • final exeDir = File(exePath).parent.path;
  • await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir); }

@override String resolveSavePath(Game game) => ''; -} \ No newline at end of file +} diff --git a/lib/core/emulator/strategies/windows_strategy.dart b/lib/core/emulator/strategies/windows_strategy.dart index 391e944..8e0a551 100644 --- a/lib/core/emulator/strategies/windows_strategy.dart +++ b/lib/core/emulator/strategies/windows_strategy.dart @@ -98,6 +98,9 @@ class WindowsStrategy extends EmulatorStrategy { } }

  • @override

  • Future launchStandalone() async {}

  • @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/xemu_strategy.dart b/lib/core/emulator/strategies/xemu_strategy.dart index 133272d..4e09a52 100644 --- a/lib/core/emulator/strategies/xemu_strategy.dart +++ b/lib/core/emulator/strategies/xemu_strategy.dart @@ -44,6 +44,16 @@ class XemuStrategy extends EmulatorStrategy { return await Process.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • final exeDir = File(exePath).parent.path;

  • await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir);

  • }

  • @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategies/xenia_strategy.dart b/lib/core/emulator/strategies/xenia_strategy.dart index 7183a07..bb5e8ca 100644 --- a/lib/core/emulator/strategies/xenia_strategy.dart +++ b/lib/core/emulator/strategies/xenia_strategy.dart @@ -44,6 +44,16 @@ class XeniaStrategy extends EmulatorStrategy { return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal); }

  • @override

  • Future launchStandalone() async {

  • final exePath = await _directoryService.findEmulatorExecutable(

  •  emulatorId, getExecutableForPlatform(),
    
  • );

  • if (exePath == null) throw Exception('$name not found. Please download it first.');

  • final exeDir = File(exePath).parent.path;

  • await Process.start(exePath, [], mode: ProcessStartMode.detached, workingDirectory: exeDir);

  • }

  • @override String resolveSavePath(Game game) => ''; } \ No newline at end of file diff --git a/lib/core/emulator/strategy_registry.dart b/lib/core/emulator/strategy_registry.dart index b60d6cf..5ad311c 100644 --- a/lib/core/emulator/strategy_registry.dart +++ b/lib/core/emulator/strategy_registry.dart @@ -52,6 +52,7 @@ class StrategyRegistry { final supported = List.from(definition['supported_platforms'] ?? []); if (Platform.isWindows && supported.contains('windows')) return true; if (Platform.isLinux && supported.contains('linux')) return true;

  •  if (Platform.isMacOS && supported.contains('macos')) return true;
     return false;
    

    }).toList(); } @@ -141,6 +142,15 @@ class StrategyRegistry { } }

  • Future clearPreferences() async {

  • final prefs = await SharedPreferences.getInstance();

  • final keys = prefs.getKeys().where((k) => k.startsWith('emulator_pref_')).toList();

  • for (final key in keys) {

  •  await prefs.remove(key);
    
  • }

  • _slugPreferences.clear();

  • }

  • EmulatorStrategy? getStrategyForSlug(String platformSlug) { if (kIsWeb) return null;

@@ -148,6 +158,7 @@ class StrategyRegistry { if (preferredId != null) { for (final strategy in _strategies) { if (strategy.emulatorId == preferredId) {

  •      debugPrint("[Registry] Using preferred emulator for $platformSlug: $preferredId");
         return strategy;
       }
     }
    

@@ -155,12 +166,16 @@ class StrategyRegistry {

 for (final strategy in _strategies) {
   if (strategy.supportedSlugs.contains(platformSlug)) {
  •    debugPrint("[Registry] Falling back to first supported emulator for $platformSlug: ${strategy.emulatorId}");
       return strategy;
     }
    

    }

  • debugPrint("[Registry] No emulator found for slug: $platformSlug"); return null; }

  • EmulatorStrategy? getStrategyById(String id) => _strategies.cast<EmulatorStrategy?>().firstWhere((s) => s?.emulatorId == id, orElse: () => null);

  • Map<String, dynamic>? getDefinition(String emulatorId) { try { return kEmulatorDefinitions.firstWhere((def) => def['id'] == emulatorId); diff --git a/lib/core/extraction/extraction_service.dart b/lib/core/extraction/extraction_service.dart index 5880ed9..7792126 100644 --- a/lib/core/extraction/extraction_service.dart +++ b/lib/core/extraction/extraction_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import '../storage/directory_service.dart';

Future _extractZipIsolate(List args) async { @@ -18,53 +19,226 @@ class ExtractionService { Future extract(String archivePath, String destDir) async { final pathLower = archivePath.toLowerCase();

  • if (pathLower.endsWith('.zip')) {
  •  final fileBytes = await File(archivePath).readAsBytes();
    
  •  await compute(_extractZipIsolate, [fileBytes, destDir]);
    
  • } else if (pathLower.endsWith('.7z')) {
  •  final sevenZipExe = await directoryService.resolveSevenZipPath();
    
  •  if (sevenZipExe == null) {
    
  •    throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.');
    
  • try {
  •  if (pathLower.endsWith('.dmg')) {
    
  •    await _handleDmg(archivePath, destDir);
    
  •  } else if (pathLower.endsWith('.tar.gz') || pathLower.endsWith('.tgz') || 
    
  •             pathLower.endsWith('.tar.xz') || pathLower.endsWith('.tar')) {
    
  •    await _handleTar(archivePath, destDir);
    
  •  } else if (pathLower.endsWith('.zip')) {
    
  •    await _handleZip(archivePath, destDir);
    
  •  } else if (pathLower.endsWith('.7z')) {
    
  •    await _handleSevenZip(archivePath, destDir);
    
  •  } else if (pathLower.endsWith('.exe')) {
    
  •    await _handleExe(archivePath, destDir);
    
  •  } else {
    
  •    await _handleGeneric(archivePath, destDir);
    
  •  }
    
  • } catch (e) {
  •  debugPrint('Extraction failed for $archivePath: $e');
    
  •  rethrow;
    
  • }
  • }
  • Future _handleTar(String archivePath, String destDir) async {
  • if (Platform.isMacOS || Platform.isLinux) {
  •  try {
    
  •    // 'tar -xf' handles gzip, xz, etc. automatically on modern systems
    
  •    final result = await Process.run(
    
  •      'tar',
    
  •      ['-xf', archivePath, '-C', destDir],
    
  •      runInShell: false,
    
  •    );
    
  •    if (result.exitCode != 0) {
    
  •      throw Exception('tar failed: ${result.stderr}');
    
  •    }
    
  •    if (Platform.isMacOS) {
    
  •      await _postExtractSanitize(destDir);
    
  •    }
    
  •  } catch (e) {
    
  •    debugPrint('Error during tar extraction: $e');
    
  •    rethrow;
     }
    
  • } else {
  •  throw Exception('tar extraction is not supported on this platform');
    
  • }
  • }
  • Future _handleDmg(String archivePath, String destDir) async {
  • if (!Platform.isMacOS) {
  •  throw Exception('DMG extraction is only supported on macOS');
    
  • }
  • final mountResult = await Process.run(
  •  'hdiutil',
    
  •  ['attach', archivePath, '-nobrowse', '-readonly'],
    
  • );
  • if (mountResult.exitCode != 0) {
  •  throw Exception('Failed to mount DMG: ${mountResult.stderr}');
    
  • }
  • String? mountPoint;
  • final lines = mountResult.stdout.toString().split('\n');
  • for (final line in lines) {
  •  if (line.contains('/Volumes/')) {
    
  •    mountPoint = line.substring(line.indexOf('/Volumes/')).trim();
    
  •    break;
    
  •  }
    
  • }
  • if (mountPoint == null) {
  •  final lastLine = lines.where((l) => l.trim().isNotEmpty).last;
    
  •  if (lastLine.contains('/Volumes/')) {
    
  •    mountPoint = lastLine.substring(lastLine.indexOf('/Volumes/')).trim();
    
  •  }
    
  • }
  • if (mountPoint == null) {
  •  throw Exception('Could not determine DMG mount point');
    
  • }
  • try {
  •  final volume = Directory(mountPoint);
    
  •  await for (final entity in volume.list()) {
    
  •    if (entity is Directory && entity.path.endsWith('.app')) {
    
  •      final cpResult = await Process.run('cp', ['-R', entity.path, destDir]);
    
  •      if (cpResult.exitCode != 0) {
    
  •        throw Exception('Failed to copy .app bundle: ${cpResult.stderr}');
    
  •      }
    
  •      await _postExtractSanitize(destDir);
    
  •      break;
    
  •    }
    
  •  }
    
  • } catch (e) {
  •  debugPrint('Error copying from DMG: $e');
    
  •  rethrow;
    
  • } finally {
  •  await Process.run('hdiutil', ['detach', mountPoint, '-force']);
    
  • }
  • }
  • Future _handleZip(String archivePath, String destDir) async {
  • if (Platform.isMacOS || Platform.isLinux) { final result = await Process.run(
  •    sevenZipExe,
    
  •    ['x', archivePath, '-o$destDir', '-y'],
    
  •    'unzip',
    
  •    ['-o', archivePath, '-d', destDir],
       runInShell: false,
     );
     if (result.exitCode != 0) {
    
  •    throw Exception('7z extraction failed: ${result.stderr}');
    
  •    throw Exception('unzip failed: ${result.stderr}');
    
  •  }
    
  •  if (Platform.isMacOS) {
    
  •    await _postExtractSanitize(destDir);
     }
    
  • } else if (pathLower.endsWith('.exe')) {
  •  // Self-extracting archive
    
  •  var result = await Process.run(
    
  • } else {
  •  final fileBytes = await File(archivePath).readAsBytes();
    
  •  await compute(_extractZipIsolate, [fileBytes, destDir]);
    
  • }
  • }
  • Future _handleSevenZip(String archivePath, String destDir) async {
  • final sevenZipExe = await directoryService.resolveSevenZipPath();
  • if (sevenZipExe == null) {
  •  throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.');
    
  • }
  • final result = await Process.run(
  •  sevenZipExe,
    
  •  ['x', archivePath, '-o$destDir', '-y'],
    
  •  runInShell: false,
    
  • );
  • if (result.exitCode != 0) {
  •  throw Exception('7z extraction failed: ${result.stderr}');
    
  • }
  • if (Platform.isMacOS) {
  •  await _postExtractSanitize(destDir);
    
  • }
  • }
  • Future _handleExe(String archivePath, String destDir) async {
  • var result = await Process.run(
  •  archivePath,
    
  •  ['-o$destDir', '-y'],
    
  •  runInShell: false,
    
  • );
  • if (result.exitCode != 0) {
  •  result = await Process.run(
       archivePath,
    
  •    ['-o$destDir', '-y'],
    
  •    runInShell: false,
    
  •    [],
    
  •    workingDirectory: destDir,
     );
    
  •  if (result.exitCode != 0) {
    
  •    // Try as a plain self-extractor with no arguments
    
  •    result = await Process.run(
    
  •      archivePath,
    
  •      [],
    
  •      workingDirectory: destDir,
    
  •    );
    
  •  }
    
  • }
  • }
  • Future _handleGeneric(String archivePath, String destDir) async {
  • bool isZip = false;
  • try {
  •  final raf = await File(archivePath).open();
    
  •  final header = await raf.read(4);
    
  •  await raf.close();
    
  •  isZip = header.length >= 2 && header[0] == 0x50 && header[1] == 0x4B;
    
  • } catch (_) {}
  • if (isZip) {
  •  await _handleZip(archivePath, destDir);
    
    } else {
  •  // Try ZIP magic bytes (PK = 0x50 0x4B)
    
  •  bool isZip = false;
    
  •  try {
    
  •    final raf = await File(archivePath).open();
    
  •    final header = await raf.read(4);
    
  •    await raf.close();
    
  •    isZip = header.length >= 2 && header[0] == 0x50 && header[1] == 0x4B;
    
  •  } catch (_) {}
    
  •  if (isZip) {
    
  •    final fileBytes = await File(archivePath).readAsBytes();
    
  •    await compute(_extractZipIsolate, [fileBytes, destDir]);
    
  •  } else {
    
  •    throw Exception('Unsupported archive format: $archivePath');
    
  •  throw Exception('Unsupported archive format: $archivePath');
    
  • }
  • }
  • Future _postExtractSanitize(String destDir) async {
  • if (!Platform.isMacOS) return;
  • // Find all .app bundles (case-insensitive)
  • final findResult = await Process.run(
  •  'find', [destDir, '-iname', '*.app', '-maxdepth', '3'],
    
  •  runInShell: false,
    
  • );
  • final appPaths = findResult.stdout.toString().trim().split('\n').where((p) => p.isNotEmpty);
  • for (final appPath in appPaths) {
  •  String finalAppPath = appPath;
    
  •  final name = p.basename(appPath);
    
  •  // Feature: Rename versioned apps to canonical names (e.g. PCSX2-v2.6.3.app -> PCSX2.app)
    
  •  // Standard versioning patterns like -v1.0, _v2.0, or just -2.0
    
  •  if (name.contains('-') || name.contains('_') || RegExp(r'\d').hasMatch(name)) {
    
  •    final baseNameMatch = name.split(RegExp(r'[-_v]'))[0];
    
  •    if (baseNameMatch.length > 3) {
    
  •      final canonicalName = '$baseNameMatch.app';
    
  •      final newPath = p.join(p.dirname(appPath), canonicalName);
    
  •      if (finalAppPath != newPath && !await Directory(newPath).exists()) {
    
  •        try {
    
  •          await Directory(appPath).rename(newPath);
    
  •          finalAppPath = newPath;
    
  •          debugPrint('[Extraction] Renamed versioned bundle: $name -> $canonicalName');
    
  •        } catch (e) {
    
  •          debugPrint('[Extraction] Failed to rename $name to $canonicalName: $e');
    
  •        }
    
  •      }
    
  •    }
     }
    
  •  await _sanitizeAppBundle(finalAppPath);
    
  • }
  • }
  • Future _sanitizeAppBundle(String appPath) async {
  • if (!Platform.isMacOS) return;
  • try {
  •  // Ensure it's executable
    
  •  await Process.run('chmod', ['-R', '+x', appPath]);
    
  •  // Remove quarantine
    
  •  await Process.run('xattr', ['-rd', 'com.apple.quarantine', appPath]);
    
  •  // deep self-sign
    
  •  await Process.run('codesign', ['--force', '--deep', '--sign', '-', appPath]);
    
  • } catch (e) {
  •  debugPrint('Warning: Could not sanitize app bundle at $appPath: $e');
    
    } } } diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart index b2382c9..c27ce97 100644 --- a/lib/core/romm/romm_models.dart +++ b/lib/core/romm/romm_models.dart @@ -72,17 +72,38 @@ class Game { hasMultipleFiles: json['has_multiple_files'] as bool? ?? false, ); }
  • Map<String, dynamic> toJson() {
  • return {
  •  'id': id,
    
  •  'name': name,
    
  •  'platform_id': platformId,
    
  •  'platform_slug': platformSlug,
    
  •  'platform_display_name': platformDisplayName,
    
  •  'path_cover_large': pathCoverLarge,
    
  •  'path_cover_small': pathCoverSmall,
    
  •  'url_cover': urlCover,
    
  •  'url_download': fileUrl,
    
  •  'file_name': fileName,
    
  •  'fs_name': fsName,
    
  •  'file_size_bytes': fileSize,
    
  •  'multi_file_path': multiFilePath,
    
  •  'has_multiple_files': hasMultipleFiles,
    
  • };
  • } }

class Platform { final int id; final String name; final String slug;

  • final int gamesCount;

    Platform({ required this.id, required this.name, required this.slug,

  • this.gamesCount = 0, });

factory Platform.fromJson(Map<String, dynamic> json) { @@ -90,6 +111,9 @@ class Platform { id: json['id'] as int? ?? 0, name: json['name']?.toString() ?? '', slug: json['slug']?.toString() ?? '',

  •  gamesCount: (json['rom_count'] as int?) ?? 
    
  •              (json['roms_count'] as int?) ?? 
    
  •              (json['games_count'] as int?) ?? 0,
    
    ); } } diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart index c268d2d..95c0a40 100644 --- a/lib/core/romm/romm_service.dart +++ b/lib/core/romm/romm_service.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'romm_models.dart';

@@ -244,35 +244,108 @@ class RommService { // ─── Save sync ─────────────────────────────────────────────────────────────

/// Uploads [saveFile] for [gameId] to RomM via POST /api/saves.

  • Future uploadSave(String gameId, File saveFile, {String slot = 'freegosy'}) async {
  • Future uploadSave(String gameId, File saveFile, {String? slot}) async { try {
  •  final now = DateTime.now();
    
  •  final ts = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}_${now.hour.toString().padLeft(2, '0')}-${now.minute.toString().padLeft(2, '0')}-${now.second.toString().padLeft(2, '0')}';
    
  •  final effectiveSlot = slot ?? 'freegosy-srm_$ts';
     final fileName = saveFile.uri.pathSegments.last;
     final formData = FormData.fromMap({
       'saveFile': await MultipartFile.fromFile(saveFile.path, filename: fileName),
     });
     final response = await _dio.post(
       '/api/saves',
    
  •    queryParameters: {'rom_id': gameId, 'emulator': 'freegosy', 'slot': slot},
    
  •    queryParameters: {'rom_id': gameId, 'emulator': 'freegosy', 'slot': effectiveSlot},
       data: formData,
       options: _authOptions,
     );
     final ok = response.statusCode != null &&
         response.statusCode! >= 200 &&
         response.statusCode! < 300;
    
  •  if (ok) {
    
  •    debugPrint('[RomM] Uploaded save: slot=$effectiveSlot');
    
  •  }
     return ok;
    

    } catch (e) {

  •  debugPrint('[RomM] Upload error: $e');
     return false;
    

    } }

  • Future pruneOldSaves(String gameId, {int keepCount = 5}) async {

  • try {

  •  final saves = await getSavesList(gameId);
    
  •  final freegosySaves = saves.where((s) =>
    
  •    (s['emulator']?.toString() ?? '') == 'freegosy'
    
  •  ).toList();
    
  •  if (freegosySaves.length <= keepCount) return;
    
  •  final toDelete = freegosySaves.sublist(keepCount);
    
  •  for (final save in toDelete) {
    
  •    final saveId = save['id'];
    
  •    if (saveId == null) continue;
    
  •    try {
    
  •      await _dio.delete('/api/saves/$saveId', options: _authOptions);
    
  •      debugPrint('[RomM] Pruned old save: id=$saveId');
    
  •    } catch (_) {}
    
  •  }
    
  • } catch (e) {

  •  debugPrint('[RomM] Prune error: $e');
    
  • }

  • }

  • /// Returns all save objects for [gameId], sorted newest first.

  • Future<List<Map<String, dynamic>>> getSavesList(String gameId) async {

  • try {

  •  final opts = _authOptions;
    
  •  final response = await _dio.get(
    
  •    '/api/saves',
    
  •    queryParameters: {'rom_id': gameId},
    
  •    options: opts,
    
  •  );
    
  •  if (response.statusCode != 200) return [];
    
  •  final List<dynamic> items;
    
  •  if (response.data is Map && response.data.containsKey('items')) {
    
  •    items = response.data['items'] as List<dynamic>;
    
  •  } else if (response.data is List) {
    
  •    items = response.data as List<dynamic>;
    
  •  } else {
    
  •    return [];
    
  •  }
    
  •  final sorted = List<Map<String, dynamic>>.from(
    
  •    items.whereType<Map<String, dynamic>>(),
    
  •  );
    
  •  // Sort by created_at descending (newest first)
    
  •  sorted.sort((a, b) {
    
  •    final ta = DateTime.tryParse(a['created_at']?.toString() ?? a['updated_at']?.toString() ?? '') ?? DateTime(0);
    
  •    final tb = DateTime.tryParse(b['created_at']?.toString() ?? b['updated_at']?.toString() ?? '') ?? DateTime(0);
    
  •    return tb.compareTo(ta);
    
  •  });
    
  •  if (sorted.isEmpty) {
    
  •    debugPrint("WARNING: Parsed saves list is empty.");
    
  •  }
    
  •  return sorted;
    
  • } catch (e) {

  •  debugPrint("ERROR in getSavesList: $e");
    
  •  return [];
    
  • }

  • }

  • /// Returns the most recently updated save object for [gameId], or null. Future<Map<String, dynamic>?> getLatestSave(String gameId) async { try {

  •  final opts = _authOptions;
     final response = await _dio.get(
       '/api/saves',
       queryParameters: {'rom_id': gameId},
    
  •    options: _authOptions,
    
  •    options: opts,
     );
    
  •  if (response.statusCode != 200) return null;
    
     final List<dynamic> items;
    

@@ -284,7 +357,9 @@ class RommService { return null; }

  •  if (items.isEmpty) return null;
    
  •  if (items.isEmpty) {
    
  •    return null;
    
  •  }
    
     // Sort by updated_at descending, return the most recent
     final sorted = List<Map<String, dynamic>>.from(
    

@@ -297,6 +372,7 @@ class RommService { }); return sorted.first; } catch (e) {

  •  debugPrint("ERROR in getLatestSave: $e");
     return null;
    

    } } @@ -309,15 +385,19 @@ class RommService { ? saveUrl : '${_normalizeBaseUrl(config.baseUrl)}$saveUrl';

  •  final opts = _authOptions.copyWith(responseType: ResponseType.bytes);
    
  •  final response = await _dio.get<List<int>>(
       url,
    
  •    options: _authOptions.copyWith(responseType: ResponseType.bytes),
    
  •    options: opts,
     );
    
  •  if (response.statusCode == 200 && response.data != null) {
       return Uint8List.fromList(response.data!);
     }
     return null;
    
    } catch (e) {
  •  debugPrint("ERROR in downloadSave: $e");
     return null;
    
    } } diff --git a/lib/core/save/save_strategy.dart b/lib/core/save/save_strategy.dart index 52e9077..abfb1a4 100644 --- a/lib/core/save/save_strategy.dart +++ b/lib/core/save/save_strategy.dart @@ -3,9 +3,21 @@ import 'dart:typed_data';

import '../romm/romm_models.dart';

+// ─── Exceptions ────────────────────────────────────────────────────────────── + +class SaveMappingRequiredException implements Exception {

  • final String message;
  • SaveMappingRequiredException([this.message = 'Manual save mapping required']);
  • @override
  • String toString() => 'SaveMappingRequiredException: $message'; +}

/// Abstract base for all save-file strategies. abstract class SaveStrategy { String get strategyId; +

  • /// Whether this strategy supports save synchronization.

  • bool get supportsSaveSync => false;

    /// Returns the local save directory for [game] given its [romPath]. Future<String?> getSaveDir(Game game, String romPath); diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart index fd7b969..049727e 100644 --- a/lib/core/save/save_sync_service.dart +++ b/lib/core/save/save_sync_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../romm/romm_models.dart'; import '../romm/romm_service.dart'; @@ -18,6 +19,7 @@ import 'strategies/melonds_save_strategy.dart'; import 'strategies/mgba_save_strategy.dart'; import 'strategies/ppsspp_save_strategy.dart'; import 'strategies/cemu_save_strategy.dart'; +import 'strategies/azahar_save_strategy.dart'; import '../emulator/strategy_registry.dart';

class SaveSyncService { @@ -37,11 +39,12 @@ class SaveSyncService { late final MgbaSaveStrategy _mgba; late final PpssppSaveStrategy _ppsspp; late final CemuSaveStrategy _cemu;

  • late final AzaharSaveStrategy _azahar;

    SaveSyncService(this._rommService, this._directoryService, this._strategyRegistry) { _retroarch = RetroArchSaveStrategy(_directoryService); _dolphin = DolphinSaveStrategy(_directoryService);

  • _eden = EdenSaveStrategy();
  • _eden = EdenSaveStrategy(onMappingResolved: saveMappedFolder); _windows = WindowsSaveStrategy(); _pcsx2 = Pcsx2SaveStrategy(_directoryService); _rpcs3 = Rpcs3SaveStrategy(_directoryService); @@ -51,14 +54,37 @@ class SaveSyncService { _mgba = MgbaSaveStrategy(); _ppsspp = PpssppSaveStrategy(_directoryService); _cemu = CemuSaveStrategy(_directoryService);
  • _azahar = AzaharSaveStrategy(_directoryService, onMappingResolved: saveMappedFolder);
  • }
  • /// Returns the manual Title ID mapping for a given game.
  • Future<String?> getMappedFolder(String gameId) async {
  • final prefs = await SharedPreferences.getInstance();
  • return prefs.getString('eden_mapping_$gameId');
  • }
  • /// Saves the manual Title ID mapping for a given game.
  • Future saveMappedFolder(String gameId, String folderName) async {
  • final prefs = await SharedPreferences.getInstance();
  • await prefs.setString('eden_mapping_$gameId', folderName);
  • }
  • /// Returns the manual Eden profile choice.
  • Future<String?> getActiveProfile() async {
  • final prefs = await SharedPreferences.getInstance();
  • return prefs.getString('active_eden_profile');
  • }
  • /// Saves the manual Eden profile choice.
  • Future saveActiveProfile(String profileId) async {
  • final prefs = await SharedPreferences.getInstance();
  • await prefs.setString('active_eden_profile', profileId); }

/// Returns the appropriate save strategy for [platformSlug], or null if unsupported. SaveStrategy? getStrategyForSlug(String? platformSlug) {

  • // print('[SaveSync] getStrategyForSlug called with: $platformSlug'); if (platformSlug != null) { final preferredId = _strategyRegistry.getPreferredEmulatorId(platformSlug);
  •  // print('[SaveSync] preferredId for $platformSlug: $preferredId');
     if (preferredId != null) {
       final id = preferredId.toLowerCase();
       if (id == 'melonds') return _melonds;
    

@@ -73,6 +99,7 @@ class SaveSyncService { if (id == 'xenia' || id == 'xenia_canary') return _xenia; if (id == 'eden') return _eden; if (id == 'windows') return _windows;

  •    if (id == 'azahar') return _azahar;
     }
    
    }

@@ -140,8 +167,7 @@ class SaveSyncService { case 'nintendo3ds': case 'new-nintendo-3ds': case 'new-nintendo-3ds-xl':

  •  case 'xbox':
    
  •    return null;
    
  •    return _azahar;
     default:
       return null;
    

    } @@ -162,6 +188,16 @@ class SaveSyncService { await prefs.setString(_hashKey(gameId, filename), hash); }

  • /// Clears the stored hash for a game, forcing the next push to upload.

  • Future clearHashCache(String gameId) async {

  • final prefs = await SharedPreferences.getInstance();

  • final keys = prefs.getKeys().where((k) => k.startsWith('last_hash_${gameId}_'));

  • for (final key in keys) {

  •  await prefs.remove(key);
    
  • }

  • debugPrint('[SyncService] Cleared hash cache for game $gameId');

  • }

  • Future _hashFile(File file) async { final bytes = await file.readAsBytes(); return md5.convert(bytes).toString(); @@ -186,15 +222,22 @@ class SaveSyncService { }

    /// Uploads all local save files for [game] to RomM.

  • ///
  • /// If [sessionStart] is provided, only files modified after that time are uploaded.
  • /// Returns true if at least one file was uploaded successfully. Future pushSaves(Game game, String romPath,
  •  {DateTime? sessionStart, String syncMode = 'both'}) async {
    
  •  {DateTime? sessionStart, String syncMode = 'both', bool force = false}) async {
    

    try { final strategy = getStrategyForSlug(game.platformSlug); if (strategy == null) return false;

  •  if (strategy is EdenSaveStrategy) {
    
  •    final mapping = await getMappedFolder(game.id);
    
  •    strategy.setManualMapping(mapping);
    
  •    final activeProfile = await getActiveProfile();
    
  •    strategy.setActiveProfileOverride(activeProfile);
    
  •  } else if (strategy is AzaharSaveStrategy) {
    
  •    final mapping = await getMappedFolder(game.id);
    
  •    strategy.setManualMapping(mapping);
    
  •  }
    
  •  final files = await strategy.getSaveFiles(
       game, romPath,
       sessionStart: sessionStart,
    

@@ -209,10 +252,16 @@ class SaveSyncService {

     if (await FileSystemEntity.isDirectory(file.path)) {
       final zipPath = '${file.path}.zip';
  •      // Write a temp metadata file into the save dir before zipping.
    
  •      // This gives each zip a unique content hash so RomM won't deduplicate.
    
  •      final metaFile = File('${file.path}/.freegosy_sync');
    
  •      await metaFile.writeAsString(DateTime.now().toIso8601String());
         final encoder = ZipFileEncoder();
         encoder.create(zipPath);
         await encoder.addDirectory(Directory(file.path));
         encoder.close();
    
  •      // Clean up the temp meta file
    
  •      if (await metaFile.exists()) await metaFile.delete();
         uploadFile = File(zipPath);
         isTempZip = true;
       }
    

@@ -224,7 +273,7 @@ class SaveSyncService { final localHash = await _hashFile(uploadFile); final storedHash = await _getStoredHash(game.id, filename);

  •    if (storedHash != null && localHash == storedHash) {
    
  •    if (!force && storedHash != null && localHash == storedHash) {
         if (isTempZip && await uploadFile.exists()) {
           await uploadFile.delete();
         }
    

@@ -242,33 +291,50 @@ class SaveSyncService { } }

  •  if (uploaded > 0) {
    
  •    await _rommService.pruneOldSaves(game.id);
    
  •  }
     return uploaded > 0;
    
    } catch (e) {
  •  if (e is SaveMappingRequiredException) rethrow;
     return false;
    
    } }
  • /// Downloads the latest save for [game] from RomM and restores it locally.
  • ///
  • /// Returns true on success.
  • Future pullSave(Game game, String romPath) async {
  • /// Returns all available saves for [gameId] from RomM.
  • Future<List<Map<String, dynamic>>> getSavesForGame(String gameId) async {
  • return _rommService.getSavesList(gameId);
  • }
  • /// Downloads a specific save for [game] from RomM and restores it locally.
  • Future pullSave(Game game, String romPath, {Map<String, dynamic>? saveData}) async { try { final strategy = getStrategyForSlug(game.platformSlug); if (strategy == null) return false;
  •  final save = await _rommService.getLatestSave(game.id);
    
  •  if (strategy is EdenSaveStrategy) {
    
  •    final mapping = await getMappedFolder(game.id);
    
  •    strategy.setManualMapping(mapping);
    
  •    final activeProfile = await getActiveProfile();
    
  •    strategy.setActiveProfileOverride(activeProfile);
    
  •  } else if (strategy is AzaharSaveStrategy) {
    
  •    final mapping = await getMappedFolder(game.id);
    
  •    strategy.setManualMapping(mapping);
    
  •  }
    
  •  final Map<String, dynamic>? save = saveData ?? await _rommService.getLatestSave(game.id);
     if (save == null) return false;
    
  •  // Check remote updated_at vs last pull time
    
  •  final remoteUpdatedAt = DateTime.tryParse(
    
  •      save['updated_at']?.toString() ?? '');
    
  •  final lastPull = await _getLastPullTime(game.id);
    
  •  if (saveData == null) {
    
  •    final remoteUpdatedAt = DateTime.tryParse(
    
  •        save['updated_at']?.toString() ?? '');
    
  •    final lastPull = await _getLastPullTime(game.id);
    
  •  if (lastPull != null &&
    
  •      remoteUpdatedAt != null &&
    
  •      !remoteUpdatedAt.isAfter(lastPull)) {
    
  •    // Remote hasn't changed since last pull, skip
    
  •    return false;
    
  •    if (lastPull != null &&
    
  •        remoteUpdatedAt != null &&
    
  •        !remoteUpdatedAt.isAfter(lastPull)) {
    
  •      return false;
    
  •    }
     }
    
     final downloadUrl = save['download_path'] as String?
    

@@ -295,4 +361,6 @@ class SaveSyncService { }

WindowsSaveStrategy get windowsSaveStrategy => _windows;

  • EdenSaveStrategy get edenSaveStrategy => _eden;
  • AzaharSaveStrategy get azaharSaveStrategy => _azahar; } diff --git a/lib/core/save/strategies/azahar_save_strategy.dart b/lib/core/save/strategies/azahar_save_strategy.dart new file mode 100644 index 0000000..f721e47 --- /dev/null +++ b/lib/core/save/strategies/azahar_save_strategy.dart @@ -0,0 +1,237 @@ +import 'dart:io' as io; +import 'package:archive/archive.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p;

+import '../../romm/romm_models.dart'; +import '../../storage/directory_service.dart'; +import '../save_strategy.dart'; + +// ─── Strategy ──────────────────────────────────────────────────────────────── + +/// Save strategy for Azahar (Nintendo 3DS) emulator. +/// +/// Follows the Eden pattern: requires manual folder mapping from the 'sdmc' +/// directory if automatic resolution is not possible. +class AzaharSaveStrategy extends SaveStrategy {

  • final DirectoryService _directoryService;

  • final Future Function(String gameId, String mapping)? onMappingResolved;

  • AzaharSaveStrategy(this._directoryService, {this.onMappingResolved});

  • @override

  • String get strategyId => '3ds';

  • @override

  • bool get supportsSaveSync => true;

  • String? _manualMapping;

  • /// Sets the manual mapping (relative path from sdmc directory).

  • void setManualMapping(String? mapping) {

  • _manualMapping = mapping;

  • }

  • Future<String?> _getAzaharSystemBase() async {

  • return await _directoryService.getEmulatorSystemDirectory('azahar');

  • }

  • @override

  • Future<String?> getSaveDir(Game game, String romPath) async {

  • if (_manualMapping == null || _manualMapping!.isEmpty) {

  •  debugPrint('[Azahar] FAILED: No manual mapping resolved for ${game.name}');
    
  •  throw SaveMappingRequiredException(
    
  •      'Could not determine save folder for "${game.name}". '
    
  •      'Please select the save folder manually from the sdmc directory.');
    
  • }

  • final base = await _getAzaharSystemBase();

  • if (base == null) return null;

  • // _manualMapping is expected to be the relative path from sdmc

  • final finalPath = p.join(base, 'sdmc', _manualMapping!);

  • debugPrint('[Azahar] Final path: $finalPath');

  • return finalPath;

  • }

  • @override

  • Future<List<io.File>> getSaveFiles(

  • Game game,

  • String romPath, {

  • DateTime? sessionStart,

  • String syncMode = 'both',

  • }) async {

  • final saveDir = await getSaveDir(game, romPath);

  • if (saveDir == null) return [];

  • final dir = io.Directory(saveDir);

  • if (!dir.existsSync()) {

  •  if (syncMode == 'push') {
    
  •    throw Exception('Local save data not found for pushing.');
    
  •  }
    
  •  return [];
    
  • }

  • // Check if directory actually contains save files

  • final hasFiles = dir.listSync(recursive: true).any((f) => f is io.File);

  • if (!hasFiles) {

  •  if (syncMode == 'push') {
    
  •    throw Exception('Save directory exists but contains no save files.');
    
  •  }
    
  •  return [];
    
  • }

  • // If sessionStart filter is set, check if any file was modified after it

  • if (sessionStart != null) {

  •  final files = dir.listSync(recursive: true).whereType<io.File>();
    
  •  final hasChanges = files.any((f) => f.statSync().modified.isAfter(sessionStart));
    
  •  if (!hasChanges) {
    
  •    debugPrint('[Azahar] No files modified since session start');
    
  •    return [];
    
  •  }
    
  • }

  • // Return the DIRECTORY as a File reference.

  • // SaveSyncService.pushSaves() detects isDirectory and zips it.

  • return [io.File(saveDir)];

  • }

  • @override

  • Future restoreSave(

  • Game game,

  • String destPath,

  • Uint8List data,

  • String filename,

  • ) async {

  • try {

  •  debugPrint('=== AZAHAR RESTORE: ${game.name} ===');
    
  •  final saveDir = await getSaveDir(game, destPath);
    
  •  if (saveDir == null) return false;
    
  •  final dir = io.Directory(saveDir);
    
  •  if (!dir.existsSync()) {
    
  •    await dir.create(recursive: true);
    
  •  }
    
  •  if (filename.toLowerCase().endsWith('.zip')) {
    
  •    final archive = ZipDecoder().decodeBytes(data);
    
  •    return await _extractArchive(archive, saveDir);
    
  •  } else {
    
  •    final filePath = p.join(saveDir, filename);
    
  •    await backupSave(filePath);
    
  •    await io.File(filePath).writeAsBytes(data);
    
  •    debugPrint('[Azahar][Restore] Wrote single file: $filePath');
    
  •    return true;
    
  •  }
    
  • } catch (e) {

  •  if (e is SaveMappingRequiredException) {
    
  •    rethrow;
    
  •  }
    
  •  debugPrint('[Azahar][Restore] ERROR: $e');
    
  •  return false;
    
  • }

  • }

  • /// Extracts a ZIP archive into [destDir], stripping a leading folder if present.

  • Future _extractArchive(Archive archive, String destDir) async {

  • try {

  •  for (final entry in archive) {
    
  •    if (entry.name.isEmpty) continue;
    
  •    // Strip leading folder if present (e.g. "00000001/save.bin" -> "save.bin")
    
  •    final segments = entry.name.split(RegExp(r'[/\\]'));
    
  •    final entryPath = (segments.length > 1)
    
  •        ? p.joinAll(segments.sublist(1))
    
  •        : entry.name;
    
  •    if (entryPath.isEmpty) continue;
    
  •    final outPath = p.join(destDir, entryPath);
    
  •    if (entry.isFile) {
    
  •      await backupSave(outPath);
    
  •      final outFile = io.File(outPath);
    
  •      await outFile.parent.create(recursive: true);
    
  •      await outFile.writeAsBytes(entry.content as List<int>);
    
  •      debugPrint('[Azahar][Extract] ${entry.name} → $outPath');
    
  •    } else {
    
  •      await io.Directory(outPath).create(recursive: true);
    
  •    }
    
  •  }
    
  •  return true;
    
  • } catch (e) {

  •  debugPrint('[Azahar][Extract] ERROR: $e');
    
  •  return false;
    
  • }

  • }

  • /// Scans the 'sdmc' directory for available save data folders.

  • /// Looks for '00000001' folders which are typical for 3DS saves.

  • Future<List<Map<String, dynamic>>> getAvailableSaveFolders() async {

  • final base = await _getAzaharSystemBase();

  • if (base == null) return [];

  • final sdmcDir = io.Directory(p.join(base, 'sdmc'));

  • if (!sdmcDir.existsSync()) {

  •  debugPrint('[Azahar] sdmc directory not found at: ${sdmcDir.path}');
    
  •  return [];
    
  • }

  • final folders = <Map<String, dynamic>>[];

  • final hex16Regex = RegExp(r'^[0-9A-Fa-f]{16}$');

  • try {

  •  await for (final entity in sdmcDir.list(recursive: true, followLinks: false)) {
    
  •    if (entity is io.Directory) {
    
  •      final name = p.basename(entity.path);
    
  •      if (name == '00000001') {
    
  •        // It's a save data folder
    
  •        DateTime? newest;
    
  •        int fileCount = 0;
    
  •        try {
    
  •          for (final f in entity.listSync(recursive: true)) {
    
  •            if (f is! io.File) continue;
    
  •            final fname = p.basename(f.path);
    
  •            if (fname.startsWith('.') || fname.endsWith('.bak')) continue;
    
  •            fileCount++;
    
  •            final stat = f.statSync();
    
  •            if (newest == null || stat.modified.isAfter(newest)) {
    
  •              newest = stat.modified;
    
  •            }
    
  •          }
    
  •        } catch (_) {}
    
  •        if (fileCount > 0) {
    
  •          final relativePath = p.relative(entity.path, from: sdmcDir.path);
    
  •          // Try to find a Title ID in the path (typically 16-hex)
    
  •          String displayName = relativePath;
    
  •          final segments = p.split(relativePath);
    
  •          for (int i = segments.length - 1; i >= 0; i--) {
    
  •            if (segments[i].length == 16 && hex16Regex.hasMatch(segments[i])) {
    
  •              displayName = segments[i];
    
  •              break;
    
  •            }
    
  •          }
    
  •          folders.add({
    
  •            'name': displayName,
    
  •            'path': relativePath,
    
  •            'lastModified': newest ?? entity.statSync().modified,
    
  •            'fileCount': fileCount,
    
  •          });
    
  •        }
    
  •      }
    
  •    }
    
  •  }
    
  • } catch (e) {

  •  debugPrint('[Azahar] Error scanning folders: $e');
    
  • }

  • folders.sort((a, b) => (b['lastModified'] as DateTime)

  •    .compareTo(a['lastModified'] as DateTime));
    
  • return folders;

  • } +} diff --git a/lib/core/save/strategies/cemu_save_strategy.dart b/lib/core/save/strategies/cemu_save_strategy.dart index 814b2d0..e92ab2a 100644 --- a/lib/core/save/strategies/cemu_save_strategy.dart +++ b/lib/core/save/strategies/cemu_save_strategy.dart @@ -1,6 +1,7 @@ -import 'dart:io'; +import 'dart:io' as io; import 'dart:typed_data'; import 'package:archive/archive_io.dart'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; import '../save_strategy.dart'; @@ -16,25 +17,25 @@ class CemuSaveStrategy extends SaveStrategy {

    Future<String?> _getEmulatorDir() async { final dir = await _directoryService.getEmulatorDirectory('cemu');

  • if (!await Directory(dir).exists()) return null;
  • return dir.replaceAll('/', '\');
  • if (!await io.Directory(dir).exists()) return null;
  • return dir; }

@override Future<String?> getSaveDir(Game game, String romPath) async { final emuDir = await _getEmulatorDir(); if (emuDir == null) return null;

  • return '$emuDir\mlc01\usr\save';
  • return p.join(emuDir, 'mlc01', 'usr', 'save'); }

@override

  • Future<List> getSaveFiles(Game game, String romPath,
  • Future<List<io.File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async { final emuDir = await _getEmulatorDir(); if (emuDir == null) return [];
  • final saveRoot = Directory('$emuDir\mlc01\usr\save\00050000');
  • final saveRoot = io.Directory(p.join(emuDir, 'mlc01', 'usr', 'save', '00050000')); if (!await saveRoot.exists()) return [];
  • return [File(saveRoot.path)]; // Return the directory as a single item
  • return [io.File(saveRoot.path)]; // Return the directory as a single item }

@override @@ -42,21 +43,22 @@ class CemuSaveStrategy extends SaveStrategy { try { final emuDir = await _getEmulatorDir(); if (emuDir == null) return false;

  •  final saveRoot = '$emuDir\\mlc01\\usr\\save';
    
  •  await Directory(saveRoot).create(recursive: true);
    
  •  final saveRoot = p.join(emuDir, 'mlc01', 'usr', 'save');
    
  •  await io.Directory(saveRoot).create(recursive: true);
     if (filename.toLowerCase().endsWith('.zip')) {
       final archive = ZipDecoder().decodeBytes(data);
       for (final entry in archive) {
         if (entry.name.contains('.bak')) continue;
    
  •      final entryPath = entry.name.replaceAll('/', '\\');
    
  •      if (entryPath.isEmpty || entryPath.endsWith('\\')) continue;
    
  •      final targetPath = '$saveRoot\\$entryPath';          if (entry.isFile) {
    
  •      final entryName = entry.name;
    
  •      if (entryName.isEmpty || entryName.endsWith('/') || entryName.endsWith('\\')) continue;
    
  •      final targetPath = p.join(saveRoot, entryName);
    
  •      if (entry.isFile) {
           await backupSave(targetPath);
    
  •        final outFile = File(targetPath);
    
  •        final outFile = io.File(targetPath);
           await outFile.parent.create(recursive: true);
           await outFile.writeAsBytes(entry.content as List<int>);
         } else {
    
  •        await Directory(targetPath).create(recursive: true);
    
  •        await io.Directory(targetPath).create(recursive: true);
         }
       }
       return true;
    

@@ -66,4 +68,4 @@ class CemuSaveStrategy extends SaveStrategy { return false; } } -} \ No newline at end of file +} diff --git a/lib/core/save/strategies/dolphin_save_strategy.dart b/lib/core/save/strategies/dolphin_save_strategy.dart index a3bf967..4d5bf93 100644 --- a/lib/core/save/strategies/dolphin_save_strategy.dart +++ b/lib/core/save/strategies/dolphin_save_strategy.dart @@ -1,5 +1,6 @@ -import 'dart:io'; +import 'dart:io' as io; import 'dart:typed_data'; +import 'package:path/path.dart' as p;

import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; @@ -7,8 +8,9 @@ import '../save_strategy.dart';

/// Save strategy for Dolphin emulator (GameCube / Wii). /// -/// Save files live in {emulatorDir}/User/GC/{region}/Card A/ +/// Refined to support macOS and Windows user directories and Wii/GC specific paths. class DolphinSaveStrategy extends SaveStrategy {

  • // ignore: unused_field final DirectoryService _directoryService;

    DolphinSaveStrategy(this._directoryService); @@ -16,6 +18,24 @@ class DolphinSaveStrategy extends SaveStrategy { @override String get strategyId => 'dolphin';

  • /// Returns the base user directory for Dolphin.

  • String _getUserDir() {

  • if (io.Platform.isMacOS) {

  •  final home = io.Platform.environment['HOME'];
    
  •  if (home == null) throw Exception('HOME environment variable not set');
    
  •  return p.join(home, 'Library', 'Application Support', 'Dolphin');
    
  • } else if (io.Platform.isWindows) {

  •  final userProfile = io.Platform.environment['USERPROFILE'];
    
  •  if (userProfile == null) throw Exception('USERPROFILE environment variable not set');
    
  •  return p.join(userProfile, 'Documents', 'Dolphin Emulator');
    
  • } else {

  •  // Default to Linux or other
    
  •  final home = io.Platform.environment['HOME'];
    
  •  if (home == null) throw Exception('HOME environment variable not set');
    
  •  return p.join(home, '.local', 'share', 'dolphin-emu');
    
  • }

  • }

  • /// Detects region code from [romPath]. String _detectRegion(String romPath) { final upper = romPath.toUpperCase(); @@ -26,101 +46,118 @@ class DolphinSaveStrategy extends SaveStrategy {

    /// Normalizes game name for comparison. String _normalizeGameName(String name) {

  • return name
  •    .toLowerCase()
    
  •    .replaceAll(RegExp(r'[^a-z0-9]'), '');
    
  • return name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
  • }
  • /// Extracts Game ID/Code from filename if possible.
  • /// Often formatted as [GAMEID] Name.ext or Name [GAMEID].ext
  • String? _extractGameId(String filename) {
  • final base = p.basenameWithoutExtension(filename);
  • // Look for 4 or 6 character uppercase alphanumeric codes
  • final match = RegExp(r'[([A-Z0-9]{4,6})]').firstMatch(base);
  • if (match != null) return match.group(1);
  • // Also try 6 char codes at the start of filename (common in some sets)
  • final startMatch = RegExp(r'^([A-Z0-9]{6})').firstMatch(base);
  • if (startMatch != null) return startMatch.group(1);
  • return null; }

@override Future<String?> getSaveDir(Game game, String romPath) async {

  • final exePath = await _directoryService.findEmulatorExecutable('dolphin', 'Dolphin.exe');
  • if (exePath == null) return null;
  • final emulatorDir = File(exePath).parent.path;
  • final region = _detectRegion(romPath);
  • return '$emulatorDir/User/GC/$region/Card A';
  • final userDir = _getUserDir();
  • if (!await io.Directory(userDir).exists()) return null;
  • final isWii = game.platformSlug?.toLowerCase() == 'wii';
  • if (isWii) {
  •  // Wii saves are in Wii/title/00010000/[TITLE_ID_HEX]
    
  •  // We need to find the title ID.
    
  •  String? titleId = _extractGameId(p.basename(romPath));
    
  •  if (titleId != null && titleId.length >= 4) {
    
  •    // Dolphin uses the 4-char ID in hex format for the folder name
    
  •    // e.g. RMCE (Mario Kart Wii) -> 524d4345
    
  •    final hexId = titleId.substring(0, 4).codeUnits
    
  •        .map((e) => e.toRadixString(16).padLeft(2, '0'))
    
  •        .join();
    
  •    final wiiPath = p.join(userDir, 'Wii', 'title', '00010000', hexId);
    
  •    if (await io.Directory(wiiPath).exists()) return wiiPath;
    
  •  }
    
  •  // Fallback: search title directory for modified folders if sessionStart is known
    
  •  return p.join(userDir, 'Wii', 'title', '00010000');
    
  • } else {
  •  // GameCube
    
  •  final region = _detectRegion(romPath);
    
  •  // First, check for GCI folder (preferred for sync)
    
  •  final gciPath = p.join(userDir, 'GC', region, 'Card A');
    
  •  if (await io.Directory(gciPath).exists()) return gciPath;
    
  •  // Fallback to the general GC folder for memory cards
    
  •  return p.join(userDir, 'GC');
    
  • } }

@override

  • Future<List> getSaveFiles(
  • Future<List<io.File>> getSaveFiles( Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
  • final saveDir = await getSaveDir(game, romPath);
  • if (saveDir == null) return [];
  • final dir = Directory(saveDir);
  • if (!await dir.exists()) return [];
  • // First pass: collect all GCI files and read their
  • // game codes from binary headers
  • final allGciFiles = [];
  • await for (final entity in dir.list()) {
  •  if (entity is! File) continue;
    
  •  final filename = entity.path
    
  •      .split(RegExp(r'[/\\]'))
    
  •      .last
    
  •      .toUpperCase();
    
  •  if (!filename.endsWith('.GCI')) continue;
    
  •  allGciFiles.add(entity);
    
  • }
  • // Read game codes from all GCI headers to find which
  • // code belongs to our target game.
  • // We find the game code by matching GCI filename
  • // against the game name from RomM.
  • // GCI filename format: XX-GAMECODE-GAME NAME.gci
  • // Extract the game code (segment between first and
  • // second dash) from filename.
  • String? targetGameCode;
  • final normalizedTarget = _normalizeGameName(game.displayName);
  • for (final gciFile in allGciFiles) {
  •  final filename = gciFile.path
    
  •      .split(RegExp(r'[/\\]'))
    
  •      .last;
    
  •  final parts = filename.split('-');
    
  •  if (parts.length < 3) continue;
    
  •  final codeFromFilename = parts[1].toUpperCase();
    
  •  // Get the name part (everything after second dash,
    
  •  // before the bracket timestamp)
    
  •  final namePart = parts.sublist(2).join('-')
    
  •      .replaceAll(RegExp(r'\[.*?\]'), '')
    
  •      .trim();
    
  •  final normalizedGciName = _normalizeGameName(namePart);
    
  •  if (normalizedGciName.contains(normalizedTarget) ||
    
  •      normalizedTarget.contains(normalizedGciName)) {
    
  •    targetGameCode = codeFromFilename;
    
  •    break;
    
  •  }
    
  • }
  • final rootSaveDir = await getSaveDir(game, romPath);
  • if (rootSaveDir == null) return [];
  • // If we found a matching game code filter by it,
  • // otherwise return empty list to avoid syncing
  • // wrong game saves
  • if (targetGameCode == null) {
  •  return [];
    
  • }
  • final result = [];
  • for (final gciFile in allGciFiles) {
  •  final filename = gciFile.path
    
  •      .split(RegExp(r'[/\\]'))
    
  •      .last;
    
  •  final parts = filename.split('-');
    
  •  if (parts.length < 2) continue;
    
  •  final codeFromFilename = parts[1].toUpperCase();
    
  • final isWii = game.platformSlug?.toLowerCase() == 'wii';
  • final result = <io.File>[];
  •  if (codeFromFilename != targetGameCode) continue;
    
  •  if (sessionStart != null) {
    
  •    final stat = await gciFile.stat();
    
  •    if (stat.modified.isBefore(sessionStart)) continue;
    
  • if (isWii) {
  •  // For Wii, if we have the specific folder, sync it (SaveSyncService will zip it)
    
  •  if (p.basename(rootSaveDir) != '00010000') {
    
  •    result.add(io.File(rootSaveDir));
    
  •  }
    
  • } else {
  •  // GameCube fuzzy matching
    
  •  final dir = io.Directory(rootSaveDir);
    
  •  if (!await dir.exists()) return [];
    
  •  final gameId = _extractGameId(p.basename(romPath))?.toUpperCase();
    
  •  final normalizedTarget = _normalizeGameName(game.displayName);
    
  •  await for (final entity in dir.list()) {
    
  •    if (entity is! io.File) continue;
    
  •    final filename = p.basename(entity.path).toUpperCase();
    
  •    // 1. Match .gci files by ID or name
    
  •    if (filename.endsWith('.GCI')) {
    
  •      bool match = false;
    
  •      if (gameId != null && filename.contains(gameId)) {
    
  •        match = true;
    
  •      } else {
    
  •        final normalizedGci = _normalizeGameName(filename);
    
  •        if (normalizedGci.contains(normalizedTarget) || normalizedTarget.contains(normalizedGci)) {
    
  •          match = true;
    
  •        }
    
  •      }
    
  •      if (match) {
    
  •        if (sessionStart == null || (await entity.stat()).modified.isAfter(sessionStart)) {
    
  •          result.add(entity);
    
  •        }
    
  •      }
    
  •    } 
    
  •    // 2. Match Memory Card files (.raw, .mcp) - only if they match region
    
  •    else if (filename.startsWith('MEMORYCARDA') && (filename.endsWith('.RAW') || filename.endsWith('.MCP'))) {
    
  •      final region = _detectRegion(romPath);
    
  •      if (filename.contains(region)) {
    
  •         if (sessionStart == null || (await entity.stat()).modified.isAfter(sessionStart)) {
    
  •          result.add(entity);
    
  •        }
    
  •      }
    
  •    }
     }
    
  •  result.add(gciFile);
    

    }

    return result; @@ -132,12 +169,12 @@ class DolphinSaveStrategy extends SaveStrategy { final saveDir = await getSaveDir(game, destPath); if (saveDir == null) return false;

  •  final dir = Directory(saveDir);
    
  •  final dir = io.Directory(saveDir);
     if (!await dir.exists()) await dir.create(recursive: true);
    
  •  final targetPath = '$saveDir/$filename';
    
  •  final targetPath = p.join(saveDir, filename);
     await backupSave(targetPath);
    
  •  await File(targetPath).writeAsBytes(data);
    
  •  await io.File(targetPath).writeAsBytes(data);
     return true;
    

    } catch (e) { return false; diff --git a/lib/core/save/strategies/duckstation_save_strategy.dart b/lib/core/save/strategies/duckstation_save_strategy.dart index c4ee211..295aa3e 100644 --- a/lib/core/save/strategies/duckstation_save_strategy.dart +++ b/lib/core/save/strategies/duckstation_save_strategy.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'dart:io' as io; import 'dart:typed_data'; import 'package:archive/archive_io.dart'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; import '../save_strategy.dart'; @@ -15,12 +17,18 @@ class DuckstationSaveStrategy extends SaveStrategy { String get strategyId => 'duckstation';

    Future<String?> _getBaseDir() async {

  • if (io.Platform.isMacOS) {

  •  final home = io.Platform.environment['HOME'];
    
  •  if (home == null) return null;
    
  •  return p.join(home, 'Library', 'Application Support', 'DuckStation');
    
  • }

  • final exePath = await _directoryService.findEmulatorExecutable( 'duckstation', 'duckstation-qt-x64-ReleaseLTCG.exe'); if (exePath == null) return null;

  • final emulatorDir = File(exePath).parent.path.replaceAll('/', '\');
  • final emulatorDir = File(exePath).parent.path;
  • if (await File('$emulatorDir\portable.txt').exists()) {
  • if (await File(p.join(emulatorDir, 'portable.txt')).exists()) { return emulatorDir; }

@@ -28,7 +36,7 @@ class DuckstationSaveStrategy extends SaveStrategy { final result = await Process.run('cmd', ['/c', 'echo %LOCALAPPDATA%'], runInShell: false); final localAppData = result.stdout.toString().trim(); if (localAppData.isNotEmpty && !localAppData.contains('%')) {

  •    return '$localAppData\\DuckStation';
    
  •    return p.join(localAppData, 'DuckStation');
     }
    
    } catch (e) { // ignore @@ -52,11 +60,11 @@ class DuckstationSaveStrategy extends SaveStrategy { final result = []; final stem = getRomStem(game);
  • final memcardsDir = Directory('$baseDir\memcards');
  • final memcardsDir = Directory(p.join(baseDir, 'memcards')); if (await memcardsDir.exists()) { await for (final entity in memcardsDir.list()) { if (entity is File &&
  •        entity.path.toLowerCase().contains(stem.toLowerCase()) &&
    
  •        p.basename(entity.path).toLowerCase().contains(stem.toLowerCase()) &&
           entity.path.toLowerCase().endsWith('.mcd')) {
         if (sessionStart != null) {
           final stat = await entity.stat();
    

@@ -67,10 +75,10 @@ class DuckstationSaveStrategy extends SaveStrategy { } }

  • final statesDir = Directory('$baseDir\savestates');
  • final statesDir = Directory(p.join(baseDir, 'savestates')); if (await statesDir.exists()) { await for (final entity in statesDir.list()) {
  •    if (entity is File && entity.path.toLowerCase().contains(stem.toLowerCase())) {
    
  •    if (entity is File && p.basename(entity.path).toLowerCase().contains(stem.toLowerCase())) {
         if (sessionStart != null) {
           final stat = await entity.stat();
           if (stat.modified.isBefore(sessionStart)) continue;
    

@@ -95,11 +103,12 @@ class DuckstationSaveStrategy extends SaveStrategy { for (final entry in archive) { if (!entry.isFile) continue; final entryLower = entry.name.toLowerCase();

  •      final targetDir = entryLower.endsWith('.mcd')
    
  •          ? '$baseDir\\memcards'
    
  •          : '$baseDir\\savestates';
    
  •      final targetDirName = entryLower.endsWith('.mcd')
    
  •          ? 'memcards'
    
  •          : 'savestates';
    
  •      final targetDir = p.join(baseDir, targetDirName);
    
  •      final targetPath = '$targetDir\\${entry.name.split('\\').last.split('/').last}';
    
  •      final targetPath = p.join(targetDir, p.basename(entry.name));
         await backupSave(targetPath);
         final outFile = File(targetPath);
         await outFile.parent.create(recursive: true);
    

@@ -109,9 +118,9 @@ class DuckstationSaveStrategy extends SaveStrategy { }

   final isState = !filename.toLowerCase().endsWith('.mcd');
  •  final targetDir = isState ? '$baseDir\\savestates' : '$baseDir\\memcards';
    
  •  final targetPath = '$targetDir\\$filename';
    
  •  await Directory(targetDir).create(recursive: true);
    
  •  final targetDirName = isState ? 'savestates' : 'memcards';
    
  •  final targetPath = p.join(baseDir, targetDirName, filename);
    
  •  await Directory(p.dirname(targetPath)).create(recursive: true);
     await backupSave(targetPath);
     await File(targetPath).writeAsBytes(data);
     return true;
    

diff --git a/lib/core/save/strategies/eden_save_strategy.dart b/lib/core/save/strategies/eden_save_strategy.dart index 9274893..3ad9bbb 100644 --- a/lib/core/save/strategies/eden_save_strategy.dart +++ b/lib/core/save/strategies/eden_save_strategy.dart @@ -1,224 +1,707 @@ -import 'dart:io'; -import 'dart:typed_data'; +import 'dart:io' as io; import 'package:archive/archive.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../save_strategy.dart';

+// ─── Exceptions ────────────────────────────────────────────────────────────── + +class ProfileConflictException implements Exception {

  • final List<Map<String, dynamic>> profiles;
  • ProfileConflictException(this.profiles);
  • @override
  • String toString() =>
  •  'ProfileConflictException: ${profiles.length} active profiles found.';
    

+} + +// ─── Strategy ──────────────────────────────────────────────────────────────── + /// Save strategy for Eden (Switch) emulator. /// -/// Save data is located in: -/// AppData/Roaming/eden/nand/user/save/0000000000000000/{profile}/{titleId}/ +/// Resolution order for Title ID: +/// 1. ROM header byte scan (.cnmt string in first 256 KB) +/// 2. Title ID extracted from ROM filename e.g. [0100704000B3A000] +/// 3. Manual mapping stored in SharedPreferences +/// 4. Throw [SaveMappingRequiredException] → UI shows folder picker +/// +/// Resolution order for Profile ID: +/// 1. Manual override (user picked from conflict dialog) +/// 2. Recency heuristic on actual save files (not dir mtime) +/// 3. Throw [ProfileConflictException] → UI shows profile picker class EdenSaveStrategy extends SaveStrategy {

  • final Future Function(String gameId, String titleId)?
  •  onMappingResolved;
    
  • EdenSaveStrategy({this.onMappingResolved});
  • @override String get strategyId => 'switch';
  • static final RegExp _titleIdRegex = RegExp(r'01[0-9A-Fa-f]{14}');
  • @override
  • bool get supportsSaveSync => true;
  • // ── Regex library ──────────────────────────────────────────────────────────
  • /// Attempt to extract a Switch title ID from [romPath] using three methods:
  • /// 1. Regex match in the filename
  • /// 2. Parse XCI header at offset 0x108 (8 bytes, reversed, hex)
  • /// 3. Scan eden save folders for the most recently modified folder
  • Future<String?> _resolveTitleId(String romPath, {DateTime? sessionStart}) async {
  • // Method 1: regex in filename
  • final fileName = romPath.replaceAll('\', '/').split('/').last;
  • final match = _titleIdRegex.firstMatch(fileName.toUpperCase());
  • if (match != null) return match.group(0)!.toUpperCase();
  • /// Matches a valid 16-char Switch Title ID anchored to full string.
  • static final RegExp _strictTitleIdRegex =
  •  RegExp(r'^01[0-9A-Fa-f]{14}$');
    
  • // Method 2: XCI header
  • final titleFromHeader = await _parseTitleIdFromXci(romPath);
  • if (titleFromHeader != null) return titleFromHeader;
  • /// Extracts a 16-char Title ID anywhere in a string.
  • static final RegExp _titleIdExtractorRegex =
  •  RegExp(r'01[0-9A-Fa-f]{14}');
    
  • /// Finds <TitleID>.cnmt in raw byte content (the header scanner).
  • static final RegExp _cnmtRegex =
  •  RegExp(r'(01[0-9A-Fa-f]{14})\.cnmt', caseSensitive: false);
    
  • /// Matches a 32-char hex string (Eden profile folder name).
  • static final RegExp _profileRegex = RegExp(r'^[0-9A-Fa-f]{32}$');
  • /// ROM file extensions we care about.
  • static const _romExtensions = ['.nsp', '.xci', '.nsz'];
  • // ── Mutable state (set per-sync from SaveSyncService) ──────────────────────
  • String? _manualMapping;
  • String? _activeProfileOverride;
  • void setManualMapping(String? titleId) => _manualMapping = titleId;
  • void setActiveProfileOverride(String? profileId) =>
  •  _activeProfileOverride = profileId;
    
  • // ═══════════════════════════════════════════════════════════════════════════
  • // LAYER 1 — Title ID Resolution
  • // ═══════════════════════════════════════════════════════════════════════════
  • /// Master resolver: header → filename → manual mapping → throw.
  • Future _resolveTitleId(String romPath, Game game) async {
  • // 1. Header byte scan (the source of truth)
  • final (fromHeader, resolvedRomPath) =
  •    await extractTitleIdFromHeader(romPath);
    
  • if (fromHeader != null) {
  •  debugPrint('[Eden] Title ID from header: $fromHeader');
    
  •  // Persist so future syncs skip the scan
    
  •  if (onMappingResolved != null) {
    
  •    await onMappingResolved!(game.id, fromHeader);
    
  •  }
    
  •  return fromHeader;
    
  • }
  • // Method 3: scan eden save folders
  • return await _scanEdenSaveFolders(sessionStart);
  • // 2. Extract from ROM filename e.g. "Splatoon 3 [0100C2500FC20000][v0].nsp"
  • final fromFilename =
  •    _extractTitleIdFromFilename(resolvedRomPath ?? romPath, game);
    
  • if (fromFilename != null) {
  •  debugPrint('[Eden] Title ID from filename: $fromFilename');
    
  •  if (onMappingResolved != null) {
    
  •    await onMappingResolved!(game.id, fromFilename);
    
  •  }
    
  •  return fromFilename;
    
  • }
  • // 3. Manual mapping (SharedPreferences, set by SaveSyncService)
  • if (_manualMapping != null && _manualMapping!.isNotEmpty) {
  •  debugPrint('[Eden] Title ID from manual mapping: $_manualMapping');
    
  •  return _manualMapping!;
    
  • }
  • // 4. Nothing worked
  • debugPrint('[Eden] FAILED: No Title ID resolved for ${game.name}');
  • throw SaveMappingRequiredException(
  •    'Could not determine Title ID for "${game.name}". '
    
  •    'Please select the save folder manually.');
    
  • }
  • /// Tries to pull a Title ID from the ROM's filename or the Game model name.
  • String? _extractTitleIdFromFilename(String romPath, Game game) {
  • // Try the actual file path first, then game.fileName, then game.name
  • for (final candidate in [
  •  p.basename(romPath),
    
  •  game.fileName ?? '',
    
  •  game.name,
    
  • ]) {
  •  if (candidate.isEmpty) continue;
    
  •  final match = _titleIdExtractorRegex.firstMatch(candidate);
    
  •  if (match != null) {
    
  •    return _normalizeToBaseId(match.group(0)!);
    
  •  }
    
  • }
  • return null; }
  • Future<String?> _parseTitleIdFromXci(String romPath) async {
  • if (!romPath.toLowerCase().endsWith('.xci')) return null;
  • /// Scans the first 256 KB of a ROM for <TitleID>.cnmt strings.
  • ///
  • /// If [romPath] doesn't point to an actual file, this method:
  • /// 1. Tries appending .nsp / .xci / .nsz
  • /// 2. Fuzzy-searches the parent directory for a matching ROM
  • /// 3. If the path is a directory, picks the largest ROM inside
  • Future<(String? titleId, String? resolvedPath)> extractTitleIdFromHeader(
  •  String romPath) async {
    
  • final actualPath = await _resolveRomFile(romPath);
  • if (actualPath == null) {
  •  debugPrint('[Eden][Scanner] No ROM file found for: $romPath');
    
  •  return (null, null);
    
  • }
  • debugPrint('[Eden][Scanner] Reading: $actualPath');
  • try {
  •  final file = File(romPath);
    
  •  if (!await file.exists()) return null;
    
  •  final file = io.File(actualPath);
     final raf = await file.open();
    
  •  try {
    
  •    await raf.setPosition(0x108);
    
  •    final bytes = await raf.read(8);
    
  •    if (bytes.length < 8) return null;
    
  •    // Reverse bytes and convert to hex
    
  •    final reversed = bytes.reversed.toList();
    
  •    final hex = reversed.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase();
    
  •    if (_titleIdRegex.hasMatch(hex)) return hex;
    
  •    return null;
    
  •  } finally {
    
  •    await raf.close();
    
  •  final bytes = await raf.read(262144); // 256 KB
    
  •  await raf.close();
    
  •  debugPrint('[Eden][Scanner] Read ${bytes.length} bytes');
    
  •  // Search for <16-hex>.cnmt pattern in the raw bytes
    
  •  final content = String.fromCharCodes(bytes);
    
  •  final match = _cnmtRegex.firstMatch(content);
    
  •  if (match != null) {
    
  •    final raw = match.group(1)!;
    
  •    final normalized = _normalizeToBaseId(raw);
    
  •    debugPrint('[Eden][Scanner] Found: ${match.group(0)} → $normalized');
    
  •    return (normalized, actualPath);
     }
    
  •  debugPrint('[Eden][Scanner] No .cnmt match in first 256 KB');
    
    } catch (e) {
  •  return null;
    
  •  debugPrint('[Eden][Scanner] ERROR: $e');
    
  • }
  • return (null, actualPath);
  • }
  • /// Force the last 3 hex chars to "000" → base game save folder.
  • /// e.g. 0100704000B3A800 → 0100704000B3A000
  • static String _normalizeToBaseId(String raw) {
  • final upper = raw.toUpperCase();
  • if (upper.length == 16) {
  •  return '${upper.substring(0, 13)}000';
    
    }
  • return upper; }
  • Future<String?> _scanEdenSaveFolders(DateTime? sessionStart) async {
  • final edenSaveRoot = await _getEdenSaveRoot();
  • if (edenSaveRoot == null) return null;
  • // ═══════════════════════════════════════════════════════════════════════════
  • // LAYER 2 — Profile ID Resolution
  • // ═══════════════════════════════════════════════════════════════════════════
  • final rootDir = Directory(edenSaveRoot);
  • if (!await rootDir.exists()) return null;
  • /// Discovers the active Eden profile folder.
  • ///
  • /// Validation criteria: a profile is "active" only if it contains at least
  • /// one Title ID subfolder that itself contains actual save data files
  • /// (e.g. save.bin). This eliminates ghost/empty profiles.
  • Future _resolveProfileId(String baseSavePath) async {
  • // 1. Manual override from conflict dialog
  • if (_activeProfileOverride != null &&
  •    _activeProfileOverride!.isNotEmpty) {
    
  •  debugPrint('[Eden][Profile] Using override: $_activeProfileOverride');
    
  •  return _activeProfileOverride!;
    
  • }
  • String? bestTitleId;
  • DateTime? bestTime;
  • // 2. Scan all 32-hex folders under .../save/0000000000000000/
  • final zeroDir =
  •    io.Directory(p.join(baseSavePath, '0000000000000000'));
    
  • if (!zeroDir.existsSync()) {
  •  throw Exception(
    
  •      'Eden save base does not exist: ${zeroDir.path}');
    
  • }
  • try {
  •  // Scan all profile directories
    
  •  await for (final profileEntry in rootDir.list()) {
    
  •    if (profileEntry is! Directory) continue;
    
  •    await for (final titleEntry in profileEntry.list()) {
    
  •      if (titleEntry is! Directory) continue;
    
  •      final candidate = titleEntry.uri.pathSegments
    
  •          .lastWhere((s) => s.isNotEmpty, orElse: () => '');
    
  •      if (!_titleIdRegex.hasMatch(candidate.toUpperCase())) continue;
    
  •      final stat = await titleEntry.stat();
    
  •      if (sessionStart != null && stat.modified.isBefore(sessionStart)) continue;
    
  •      if (bestTime == null || stat.modified.isAfter(bestTime)) {
    
  •        bestTime = stat.modified;
    
  •        bestTitleId = candidate.toUpperCase();
    
  • final candidates = <Map<String, dynamic>>[];
  • for (final entity in zeroDir.listSync()) {
  •  if (entity is! io.Directory) continue;
    
  •  final name = p.basename(entity.path);
    
  •  if (!_profileRegex.hasMatch(name)) continue;
    
  •  // Find the newest *file* timestamp inside any Title ID subfolder.
    
  •  // Critical: we check FILE mtime, not directory mtime.
    
  •  // This kills the ghost profile bug.
    
  •  DateTime? newestFileTime;
    
  •  int saveFileCount = 0;
    
  •  for (final titleDir in entity.listSync()) {
    
  •    if (titleDir is! io.Directory) continue;
    
  •    final titleName = p.basename(titleDir.path);
    
  •    if (!titleName.startsWith('01') || titleName.length != 16) continue;
    
  •    try {
    
  •      for (final file in titleDir.listSync(recursive: true)) {
    
  •        if (file is! io.File) continue;
    
  •        final fname = p.basename(file.path);
    
  •        if (fname.startsWith('.') || fname.endsWith('.bak')) continue;
    
  •        saveFileCount++;
    
  •        final stat = file.statSync();
    
  •        if (newestFileTime == null ||
    
  •            stat.modified.isAfter(newestFileTime)) {
    
  •          newestFileTime = stat.modified;
    
  •        }
         }
    
  •    } catch (_) {
    
  •      // Permission errors, broken symlinks — skip
       }
     }
    
  • } catch (e) {
  •  // Error handled silently
    
  •  // Only consider profiles that actually contain save files
    
  •  if (newestFileTime != null && saveFileCount > 0) {
    
  •    candidates.add({
    
  •      'id': name,
    
  •      'newestFile': newestFileTime,
    
  •      'fileCount': saveFileCount,
    
  •    });
    
  •  }
    
    }
  • return bestTitleId;
  • }
  • if (candidates.isEmpty) {
  •  throw Exception(
    
  •      'No active Eden profiles found in ${zeroDir.path}. '
    
  •      'Have you played any games in Eden yet?');
    
  • }
  • Future<String?> _getEdenSaveRoot() async {
  • try {
  •  final result = await Process.run(
    
  •    'cmd', ['/c', 'echo %APPDATA%'],
    
  •    runInShell: false,
    
  •  );
    
  •  final appData = result.stdout.toString().trim();
    
  •  if (appData.isEmpty || appData.contains('%APPDATA%')) return null;
    
  •  return '$appData/eden/nand/user/save/0000000000000000';
    
  • } catch (e) {
  •  return null;
    
  • // Sort by newest save file activity
  • candidates.sort((a, b) => (b['newestFile'] as DateTime)
  •    .compareTo(a['newestFile'] as DateTime));
    
  • // Single candidate → done
  • if (candidates.length == 1) {
  •  final id = candidates.first['id'] as String;
    
  •  debugPrint('[Eden][Profile] Single active profile: $id');
    
  •  return id;
    
    }
  • // Multiple candidates: check if the winner is clearly ahead
  • final winner = candidates[0];
  • final runnerUp = candidates[1];
  • final gap = (winner['newestFile'] as DateTime)
  •    .difference(runnerUp['newestFile'] as DateTime);
    
  • // Auto-pick if >1h newer or runner-up has very few files (ghost)
  • if (gap.inHours >= 1 ||
  •    (runnerUp['fileCount'] as int) <= 1) {
    
  •  final id = winner['id'] as String;
    
  •  debugPrint(
    
  •      '[Eden][Profile] Auto-selected: $id '
    
  •      '(gap: ${gap.inMinutes}m, '
    
  •      'runner-up files: ${runnerUp['fileCount']})');
    
  •  return id;
    
  • }
  • // Genuine conflict
  • debugPrint(
  •    '[Eden][Profile] CONFLICT: ${candidates.map((c) => c['id']).join(', ')}');
    
  • throw ProfileConflictException(candidates); }
  • Future<String?> _getSaveDirForTitleId(String titleId) async {
  • final saveRoot = await _getEdenSaveRoot();
  • if (saveRoot == null) return null;
  • // ═══════════════════════════════════════════════════════════════════════════
  • // ROM file resolution (fuzzy matching)
  • // ═══════════════════════════════════════════════════════════════════════════
  • final rootDir = Directory(saveRoot);
  • if (!await rootDir.exists()) await rootDir.create(recursive: true);
  • /// Given a path that might be missing an extension, might be a directory,
  • /// or might have a slightly different name than the actual file on disk,
  • /// find the real ROM file.
  • Future<String?> _resolveRomFile(String romPath) async {
  • // Case 1: Exact path exists and is a file
  • if (io.File(romPath).existsSync()) return romPath;
  • try {
  •  await for (final entry in rootDir.list()) {
    
  •    if (entry is! Directory) continue;
    
  •    final titleDir = Directory('${entry.path}/$titleId');
    
  •    if (await titleDir.exists()) return titleDir.path;
    
  • // Case 2: Try appending known extensions
  • for (final ext in _romExtensions) {
  •  final withExt = '$romPath$ext';
    
  •  if (io.File(withExt).existsSync()) {
    
  •    debugPrint('[Eden][Resolve] Extension fix: $withExt');
    
  •    return withExt;
     }
    
  • }
  • // Case 3: Path is a directory → find largest ROM inside
  • if (io.Directory(romPath).existsSync()) {
  •  return _findLargestRomInDir(romPath);
    
  • }
  • // Case 4: Fuzzy search the parent directory
  • final parentPath = p.dirname(romPath);
  • final parentDir = io.Directory(parentPath);
  • if (!parentDir.existsSync()) return null;
  •  final profiles = await rootDir.list().where((e) => e is Directory).toList();
    
  •  if (profiles.isNotEmpty) {
    
  •    final path = '${profiles.first.path}/$titleId';
    
  •    await Directory(path).create(recursive: true);
    
  •    return path;
    
  • final baseName = p.basenameWithoutExtension(romPath);
  • final searchTokens = _tokenize(baseName);
  • if (searchTokens.isEmpty) return null;
  • io.File? bestMatch;
  • int bestScore = 0;
  • for (final entity in parentDir.listSync()) {
  •  if (entity is! io.File) continue;
    
  •  final fileName = p.basename(entity.path).toLowerCase();
    
  •  if (!_romExtensions.any((ext) => fileName.endsWith(ext))) continue;
    
  •  final fileTokens = _tokenize(p.basenameWithoutExtension(entity.path));
    
  •  int score = 0;
    
  •  for (final token in searchTokens) {
    
  •    if (fileTokens.contains(token)) score++;
     }
    
  •  throw Exception('Launch the game in Eden at least once before syncing saves');
    
  • } catch (e) {
  •  rethrow;
    
  •  // Require at least half the search tokens to match
    
  •  if (score > bestScore && score >= (searchTokens.length / 2).ceil()) {
    
  •    bestScore = score;
    
  •    bestMatch = entity;
    
  •  }
    
    }
  • }

  • @override

  • Future<String?> getSaveDir(Game game, String romPath) async {

  • final titleId = await _resolveTitleId(romPath);

  • if (titleId == null) return null;

  • if (bestMatch != null) {
  •  debugPrint(
    
  •      '[Eden][Resolve] Fuzzy match: ${bestMatch.path} '
    
  •      '(score: $bestScore/${searchTokens.length})');
    
  •  return bestMatch.path;
    
  • }
  • final saveRoot = await _getEdenSaveRoot();
  • if (saveRoot == null) return null;
  • // Case 4b: Fuzzy match DIRECTORIES in parent (multi-file ROMs)
  • // e.g. path is ".../switch/Snipperclips Cut it out Together" but actual
  • // dir is ".../switch/Snipperclips_ Cut It Out, Together!"
  • for (final entity in parentDir.listSync()) {
  •  if (entity is! io.Directory) continue;
    
  •  final dirName = p.basename(entity.path);
    
  •  final dirTokens = _tokenize(dirName);
    
  •  int score = 0;
    
  •  for (final token in searchTokens) {
    
  •    if (dirTokens.contains(token)) score++;
    
  •  }
    
  •  if (score >= (searchTokens.length / 2).ceil() && score > 0) {
    
  •    final found = _findLargestRomInDir(entity.path);
    
  •    if (found != null) {
    
  •      debugPrint('[Eden][Resolve] Fuzzy dir match: ${entity.path} → $found');
    
  •      return found;
    
  •    }
    
  •  }
    
  • }
  • // Find the first profile directory that contains this title
  • final rootDir = Directory(saveRoot);
  • if (!await rootDir.exists()) return null;
  • // Case 5: Try one level up (multi-file ROMs in sibling dirs)

  • final grandparent = io.Directory(p.dirname(parentPath));

  • if (grandparent.existsSync()) {

  •  for (final subDir in grandparent.listSync()) {
    
  •    if (subDir is! io.Directory) continue;
    
  •    final dirName = p.basename(subDir.path).toLowerCase();
    
  •    final dirTokens = _tokenize(dirName);
    
  •    int score = 0;
    
  •    for (final token in searchTokens) {
    
  •      if (dirTokens.contains(token)) score++;
    
  •    }
    
  •    if (score >= (searchTokens.length / 2).ceil()) {
    
  •      final found = _findLargestRomInDir(subDir.path);
    
  •      if (found != null) {
    
  •        debugPrint('[Eden][Resolve] Found in sibling dir: $found');
    
  •        return found;
    
  •      }
    
  •    }
    
  •  }
    
  • }

  • return null;

  • }

  • /// Find the largest .nsp/.xci/.nsz file in a directory tree.

  • String? _findLargestRomInDir(String dirPath) {

  • io.File? largest;

  • int maxSize = -1;

    try {

  •  await for (final entry in rootDir.list()) {
    
  •    if (entry is! Directory) continue;
    
  •    final titleDir = Directory('${entry.path}/$titleId');
    
  •    if (await titleDir.exists()) return titleDir.path;
    
  •  }
    
  •  // Default profile path (no profile folders yet)
    
  •  final profileDirs = await rootDir.list().toList();
    
  •  if (profileDirs.isNotEmpty && profileDirs.first is Directory) {
    
  •    return '${profileDirs.first.path}/$titleId';
    
  •  for (final entity
    
  •      in io.Directory(dirPath).listSync(recursive: true)) {
    
  •    if (entity is! io.File) continue;
    
  •    final name = entity.path.toLowerCase();
    
  •    if (!_romExtensions.any((ext) => name.endsWith(ext))) continue;
    
  •    final size = entity.lengthSync();
    
  •    if (size > maxSize) {
    
  •      maxSize = size;
    
  •      largest = entity;
    
  •    }
     }
    
  • } catch (e) {
  •  // Error handled silently
    
  • } catch (_) {}

  • return largest?.path;

  • }

  • /// Splits a filename into lowercase tokens for fuzzy comparison.

  • /// "Snipperclips - Plus [0100704000B3A000]" → {snipperclips, plus}

  • static Set _tokenize(String input) {

  • return input

  •    .toLowerCase()
    
  •    .replaceAll(RegExp(r'\[[^\]]*\]'), '') // strip [brackets]
    
  •    .replaceAll(RegExp(r'\([^)]*\)'), '') // strip (parens)
    
  •    .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') // non-alphanum → space
    
  •    .trim()
    
  •    .split(RegExp(r'\s+'))
    
  •    .where((t) => t.length > 1) // drop single-char noise
    
  •    .toSet();
    
  • }

  • // ═══════════════════════════════════════════════════════════════════════════

  • // Platform-specific save base path

  • // ═══════════════════════════════════════════════════════════════════════════

  • String? _getEdenSaveBase() {

  • if (io.Platform.isMacOS || io.Platform.isLinux) {

  •  final home = io.Platform.environment['HOME'];
    
  •  if (home == null) return null;
    
  •  return p.join(home, '.local', 'share', 'eden', 'nand', 'user', 'save');
    
  • } else if (io.Platform.isWindows) {

  •  final appData = io.Platform.environment['APPDATA'];
    
  •  if (appData == null) return null;
    
  •  return p.join(appData, 'eden', 'nand', 'user', 'save');
    

    } return null; }

  • // ═══════════════════════════════════════════════════════════════════════════

  • // Public API — SaveStrategy overrides

  • // ═══════════════════════════════════════════════════════════════════════════

  • @override -Future<List> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {

  • Future<String?> getSaveDir(Game game, String romPath) async {

  • debugPrint('=== EDEN SAVE DIR: ${game.name} ===');

  • final base = _getEdenSaveBase();

  • if (base == null) {

  •  throw Exception('Cannot determine Eden save path on this platform.');
    
  • }

  • final profileId = await _resolveProfileId(base);

  • final titleId = await _resolveTitleId(romPath, game);

  • // Single 0000...00 layer, then profile, then title

  • final finalPath =

  •    p.join(base, '0000000000000000', profileId, titleId);
    
  • debugPrint('[Eden] Final path: $finalPath');

  • if (io.Directory(finalPath).existsSync()) {

  •  return finalPath;
    
  • }

  • // Path doesn't exist yet — return it anyway.

  • // pushSaves checks for files inside, pull creates the dir during restore.

  • debugPrint('[Eden] Path does not exist on disk yet: $finalPath');

  • return finalPath;

  • }

  • @override

  • Future<List<io.File>> getSaveFiles(

  • Game game,

  • String romPath, {

  • DateTime? sessionStart,

  • String syncMode = 'both',

  • }) async { final saveDir = await getSaveDir(game, romPath); if (saveDir == null) return [];

  • final dir = Directory(saveDir);
  • if (!await dir.exists()) return [];
  • // Return a dummy File pointing to the directory
  • // SaveSyncService will detect it's a directory and zip it
  • return [File(saveDir)];
  • final dir = io.Directory(saveDir);

  • if (!dir.existsSync()) {

  •  if (syncMode == 'push') {
    
  •    throw Exception('Local save data not found for pushing.');
    
  •  }
    
  •  return [];
    
  • }

  • // Check if directory actually contains save files

  • final files = dir

  •    .listSync(recursive: true)
    
  •    .whereType<io.File>()
    
  •    .where((f) {
    
  •  final name = p.basename(f.path);
    
  •  return !name.startsWith('.') && !name.endsWith('.bak');
    
  • }).toList();

  • if (files.isEmpty) {

  •  if (syncMode == 'push') {
    
  •    throw Exception(
    
  •        'Save directory exists but contains no save files.');
    
  •  }
    
  •  return [];
    
  • }

  • // If sessionStart filter is set, check if any file was modified after it

  • if (sessionStart != null) {

  •  final hasChanges = files.any(
    
  •      (f) => f.statSync().modified.isAfter(sessionStart));
    
  •  if (!hasChanges) {
    
  •    debugPrint('[Eden] No files modified since session start');
    
  •    return [];
    
  •  }
    
  • }

  • // Return the DIRECTORY as a File reference.

  • // SaveSyncService.pushSaves() detects isDirectory and zips it.

  • return [io.File(saveDir)]; }

  • @override

  • Future restoreSave(

  • Game game,

  • String destPath, // This is the ROM path, passed through from pullSave

  • Uint8List data,

  • String filename,

  • ) async {

  • try {

  •  debugPrint('=== EDEN RESTORE: ${game.name} ===');
    
  •  final base = _getEdenSaveBase();
    
  •  if (base == null) return false;
    
  •  final profileId = await _resolveProfileId(base);
    
  • @override
  • Future restoreSave(Game game, String destPath, Uint8List data, String filename) async {
  •  try {
    
  •    final titleMatch = _titleIdRegex.firstMatch(filename.toUpperCase());
    
  •    final saveDir = titleMatch != null
    
  •        ? await _getSaveDirForTitleId(titleMatch.group(0)!)
    
  •        : await getSaveDir(game, destPath);
    
  •    if (saveDir == null) return false;
    
  •  // Resolve Title ID using the ROM path (destPath = romPath from pullSave)
    
  •  String? titleId;
    
  •    final dir = Directory(saveDir);
    
  •    if (!await dir.exists()) await dir.create(recursive: true);
    
  •  // 1. Header scan on the ROM
    
  •  final (headerId, resolvedPath) = await extractTitleIdFromHeader(destPath);
    
  •  titleId = headerId;
    
  •  if (titleId != null) {
    
  •    debugPrint('[Eden][Restore] Title ID from header: $titleId');
    
  •  }
    
  •    // RomM packages saves as zips — extract into the title directory.
    
  •    if (filename.toLowerCase().endsWith('.zip')) {
    
  •      return await _extractZipSave(data, saveDir);
    
  •  // 2. Filename extraction
    
  •  if (titleId == null) {
    
  •    titleId = _extractTitleIdFromFilename(resolvedPath ?? destPath, game);
    
  •    if (titleId != null) {
    
  •      debugPrint('[Eden][Restore] Title ID from filename: $titleId');
       }
    
  •  }
    
  •    final targetPath = '$saveDir/$filename';
    
  •    await backupSave(targetPath);
    
  •    await File(targetPath).writeAsBytes(data);
    
  •  // 3. Check inside ZIP entries for Title ID folder names
    
  •  Archive? archive;
    
  •  if (titleId == null && filename.toLowerCase().endsWith('.zip')) {
    
  •    archive = ZipDecoder().decodeBytes(data);
    
  •    for (final entry in archive) {
    
  •      final match = _titleIdExtractorRegex.firstMatch(entry.name);
    
  •      if (match != null) {
    
  •        titleId = _normalizeToBaseId(match.group(0)!);
    
  •        debugPrint('[Eden][Restore] Title ID from ZIP entry: $titleId');
    
  •        break;
    
  •      }
    
  •    }
    
  •  }
    
  •  // 4. Manual mapping
    
  •  if (titleId == null &&
    
  •      _manualMapping != null &&
    
  •      _manualMapping!.isNotEmpty) {
    
  •    titleId = _manualMapping;
    
  •    debugPrint('[Eden][Restore] Title ID from manual mapping: $titleId');
    
  •  }
    
  •  // 5. Give up
    
  •  if (titleId == null) {
    
  •    debugPrint('[Eden][Restore] FAILED: No Title ID resolved');
    
  •    throw SaveMappingRequiredException();
    
  •  }
    
  •  // Construct target and create if needed
    
  •  final targetPath =
    
  •      p.join(base, '0000000000000000', profileId, titleId);
    
  •  debugPrint('[Eden][Restore] Target: $targetPath');
    
  •  io.Directory(targetPath).createSync(recursive: true);
    
  •  // Extract or write
    
  •  if (filename.toLowerCase().endsWith('.zip')) {
    
  •    archive ??= ZipDecoder().decodeBytes(data);
    
  •    return await _extractArchive(archive, targetPath);
    
  •  } else {
    
  •    final filePath = p.join(targetPath, filename);
    
  •    await backupSave(filePath);
    
  •    await io.File(filePath).writeAsBytes(data);
    
  •    debugPrint('[Eden][Restore] Wrote single file: $filePath');
       return true;
    
  •  } catch (e) {
    
  •  }
    
  • } catch (e) {
  •  if (e is SaveMappingRequiredException ||
    
  •      e is ProfileConflictException) {
       rethrow;
     }
    
  •  debugPrint('[Eden][Restore] ERROR: $e');
    
  •  return false;
    
    }
  • }
  • // ═══════════════════════════════════════════════════════════════════════════
  • // Helpers
  • // ═══════════════════════════════════════════════════════════════════════════
  • Future _extractZipSave(Uint8List data, String destDir) async {
  • /// Lists all Title ID save folders for the active profile.
  • /// Used by the UI for manual folder selection.
  • Future<List<Map<String, dynamic>>> getAvailableSaveFolders() async {
  • final base = _getEdenSaveBase();
  • if (base == null) return [];
  • String profileId;
  • try {
  •  profileId = await _resolveProfileId(base);
    
  • } catch (_) {
  •  return [];
    
  • }
  • final profileDir =
  •    io.Directory(p.join(base, '0000000000000000', profileId));
    
  • if (!profileDir.existsSync()) return [];
  • final folders = <Map<String, dynamic>>[];
  • for (final entity in profileDir.listSync()) {
  •  if (entity is! io.Directory) continue;
    
  •  final name = p.basename(entity.path);
    
  •  if (!_strictTitleIdRegex.hasMatch(name)) continue;
    
  •  DateTime? newest;
    
  •  int fileCount = 0;
    
  •  for (final f in entity.listSync(recursive: true)) {
    
  •    if (f is! io.File) continue;
    
  •    fileCount++;
    
  •    final mod = f.statSync().modified;
    
  •    if (newest == null || mod.isAfter(newest)) newest = mod;
    
  •  }
    
  •  if (fileCount > 0) {
    
  •    folders.add({
    
  •      'name': name,
    
  •      'lastModified': newest,
    
  •      'fileCount': fileCount,
    
  •    });
    
  •  }
    
  • }
  • folders.sort((a, b) => (b['lastModified'] as DateTime)
  •    .compareTo(a['lastModified'] as DateTime));
    
  • return folders;
  • }
  • /// Extracts a ZIP archive into [destDir], stripping a leading Title ID
  • /// folder if present (so the save files land directly in the target).
  • Future _extractArchive(Archive archive, String destDir) async { try {
  •  final archive = ZipDecoder().decodeBytes(data);
     for (final entry in archive) {
    
  •    // Strip the leading titleId folder from zip entry names
    
  •  final entryName = entry.name.contains('/')
    
  •      ? entry.name.substring(entry.name.indexOf('/') + 1)
    
  •      : entry.name;
    
  •  if (entryName.isEmpty) continue;
    
  •  final outPath = '$destDir/$entryName';
    
  •    if (entry.name.isEmpty) continue;
    
  •    // Strip leading Title ID folder from path if present.
    
  •    // e.g. "0100704000B3A000/save.bin" → "save.bin"
    
  •    final segments = entry.name.split(RegExp(r'[/\\]'));
    
  •    final entryPath = (segments.length > 1 &&
    
  •            _strictTitleIdRegex.hasMatch(segments.first))
    
  •        ? p.joinAll(segments.sublist(1))
    
  •        : entry.name;
    
  •    if (entryPath.isEmpty) continue;
    
  •    final outPath = p.join(destDir, entryPath);
       if (entry.isFile) {
         await backupSave(outPath);
    
  •      final outFile = File(outPath);
    
  •      final outFile = io.File(outPath);
         await outFile.parent.create(recursive: true);
         await outFile.writeAsBytes(entry.content as List<int>);
    
  •      debugPrint('[Eden][Extract] ${entry.name} → $outPath');
       } else {
    
  •      await Directory(outPath).create(recursive: true);
    
  •      await io.Directory(outPath).create(recursive: true);
       }
     }
     return true;
    
    } catch (e) {
  •  debugPrint('[Eden][Extract] ERROR: $e');
     return false;
    
    } } diff --git a/lib/core/save/strategies/mgba_save_strategy.dart b/lib/core/save/strategies/mgba_save_strategy.dart index 6cdde1b..96852b9 100644 --- a/lib/core/save/strategies/mgba_save_strategy.dart +++ b/lib/core/save/strategies/mgba_save_strategy.dart @@ -1,5 +1,6 @@ -import 'dart:io'; +import 'dart:io' as io; import 'dart:typed_data'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../save_strategy.dart';

@@ -10,15 +11,15 @@ class MgbaSaveStrategy extends SaveStrategy {

@override Future<String?> getSaveDir(Game game, String romPath) async {

  • return File(romPath).parent.path;
  • return io.File(romPath).parent.path; }

@override

  • Future<List> getSaveFiles(Game game, String romPath,
  • Future<List<io.File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
  • final romFile = File(romPath);
  • final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'.[^.]+$'), '');
  • final saveFile = File('${File(romPath).parent.path}/$romStem.sav');
  • final romFile = io.File(romPath);

  • final romStem = p.basenameWithoutExtension(romFile.path);

  • final saveFile = io.File(p.join(romFile.parent.path, '$romStem.sav'));

    if (await saveFile.exists()) { if (sessionStart != null) { @@ -29,7 +30,7 @@ class MgbaSaveStrategy extends SaveStrategy { } else { // Fallback to getRomStem(game) final fallbackStem = getRomStem(game);

  •  final fallbackSaveFile = File('${File(romPath).parent.path}/$fallbackStem.sav');
    
  •  final fallbackSaveFile = io.File(p.join(romFile.parent.path, '$fallbackStem.sav'));
     if (await fallbackSaveFile.exists()) {
       if (sessionStart != null) {
         final stat = await fallbackSaveFile.stat();
    

@@ -45,22 +46,14 @@ class MgbaSaveStrategy extends SaveStrategy { Future restoreSave( Game game, String destPath, Uint8List data, String filename) async { try {

  •  final romFile = File(destPath);
    
  •  final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), '');
    
  •  final targetPath = '${File(destPath).parent.path}/$romStem.sav';
    
  •  final romFile = io.File(destPath);
    
  •  final romStem = p.basenameWithoutExtension(romFile.path);
    
  •  final targetPath = p.join(romFile.parent.path, '$romStem.sav');
    
  •  if (await File(targetPath).exists()) {
    
  •    await backupSave(targetPath);
    
  •    await File(targetPath).writeAsBytes(data);
    
  •    return true;
    
  •  } else {
    
  •    // Fallback to getRomStem(game)
    
  •    final fallbackStem = getRomStem(game);
    
  •    final fallbackTargetPath = '${File(destPath).parent.path}/$fallbackStem.sav';
    
  •    await backupSave(fallbackTargetPath);
    
  •    await File(fallbackTargetPath).writeAsBytes(data);
    
  •    return true;
    
  •  }
    
  •  // Prefer matching the actual ROM filename stem
    
  •  await backupSave(targetPath);
    
  •  await io.File(targetPath).writeAsBytes(data);
    
  •  return true;
    
    } catch (e) { return false; } diff --git a/lib/core/save/strategies/pcsx2_save_strategy.dart b/lib/core/save/strategies/pcsx2_save_strategy.dart index abc7446..1e124fd 100644 --- a/lib/core/save/strategies/pcsx2_save_strategy.dart +++ b/lib/core/save/strategies/pcsx2_save_strategy.dart @@ -1,13 +1,14 @@ -import 'dart:io'; +import 'dart:io' as io; import 'package:archive/archive_io.dart'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; import '../save_strategy.dart';

/// Save strategy for PCSX2 (PlayStation 2). -/// Memcards: {emulatorDir}\memcards*.ps2 -/// States: {emulatorDir}\sstates{stem}.. +/// Memcards: {systemDir}/memcards/.ps2 +/// States: {systemDir}/sstates/{stem}.*. class Pcsx2SaveStrategy extends SaveStrategy { final DirectoryService _directoryService;

@@ -16,33 +17,31 @@ class Pcsx2SaveStrategy extends SaveStrategy { @override String get strategyId => 'pcsx2';

  • Future<String?> _getExeDir() async {
  • final exePath = await _directoryService.findEmulatorExecutable(
  •    'pcsx2', 'pcsx2-qt.exe');
    
  • if (exePath == null) return null;
  • return File(exePath).parent.path.replaceAll('/', '\');
  • Future<String?> _getSaveRoot() async {
  • return await _directoryService.getEmulatorSystemDirectory('pcsx2'); }

@override Future<String?> getSaveDir(Game game, String romPath) async {

  • final exeDir = await _getExeDir();
  • if (exeDir == null) return null;
  • return '$exeDir\memcards';
  • final root = await _getSaveRoot();
  • if (root == null) return null;
  • return p.join(root, 'memcards'); }

@override

  • Future<List> getSaveFiles(Game game, String romPath,
  • Future<List<io.File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
  • final exeDir = await _getExeDir();
  • if (exeDir == null) return [];
  • final root = await _getSaveRoot();
  • if (root == null) return [];
  • final result = [];
  • final result = <io.File>[];

    // Memory cards — scan all .ps2 files in memcards folder

  • final memcardsDir = Directory('$exeDir\memcards');
  • // Note: This matches the existing behavior of syncing entire cards.
  • final memcardsDir = io.Directory(p.join(root, 'memcards')); if (await memcardsDir.exists()) { await for (final entity in memcardsDir.list()) {
  •    if (entity is! File) continue;
    
  •    if (entity is! io.File) continue;
       if (!entity.path.toLowerCase().endsWith('.ps2')) continue;
       if (sessionStart != null) {
         final stat = await entity.stat();
    

@@ -54,11 +53,11 @@ class Pcsx2SaveStrategy extends SaveStrategy {

 // Save states — named after ROM stem
 final stem = getRomStem(game);
  • final statesDir = Directory('$exeDir\sstates');
  • final statesDir = io.Directory(p.join(root, 'sstates')); if (await statesDir.exists()) { await for (final entity in statesDir.list()) {
  •    if (entity is! File) continue;
    
  •    if (!entity.path.contains(stem)) continue;
    
  •    if (entity is! io.File) continue;
    
  •    if (!p.basename(entity.path).contains(stem)) continue;
       if (sessionStart != null) {
         final stat = await entity.stat();
         if (stat.modified.isBefore(sessionStart)) continue;
    

@@ -74,8 +73,8 @@ class Pcsx2SaveStrategy extends SaveStrategy { Future restoreSave( Game game, String destPath, Uint8List data, String filename) async { try {

  •  final exeDir = await _getExeDir();
    
  •  if (exeDir == null) return false;
    
  •  final root = await _getSaveRoot();
    
  •  if (root == null) return false;
    
     // Cloud saves come as zips — extract into the appropriate directory
     if (filename.toLowerCase().endsWith('.zip')) {
    

@@ -84,13 +83,13 @@ class Pcsx2SaveStrategy extends SaveStrategy { // Determine target dir by file extension final entryLower = entry.name.toLowerCase(); final targetDir = entryLower.endsWith('.ps2')

  •          ? '$exeDir\\memcards'
    
  •          : '$exeDir\\sstates';
    
  •          ? p.join(root, 'memcards')
    
  •          : p.join(root, 'sstates');
    
         if (entry.isFile) {
    
  •        final targetPath = '$targetDir\\${entry.name}';
    
  •        final targetPath = p.join(targetDir, entry.name);
           await backupSave(targetPath);
    
  •        final outFile = File(targetPath);
    
  •        final outFile = io.File(targetPath);
           await outFile.parent.create(recursive: true);
           await outFile.writeAsBytes(entry.content as List<int>);
         }
    

@@ -101,11 +100,11 @@ class Pcsx2SaveStrategy extends SaveStrategy { // Single file fallback final isState = filename.contains('.') && int.tryParse(filename.split('.').last) != null;

  •  final targetDir = isState ? '$exeDir\\sstates' : '$exeDir\\memcards';
    
  •  await Directory(targetDir).create(recursive: true);
    
  •  final targetPath = '$targetDir\\$filename';
    
  •  final targetDir = isState ? p.join(root, 'sstates') : p.join(root, 'memcards');
    
  •  await io.Directory(targetDir).create(recursive: true);
    
  •  final targetPath = p.join(targetDir, filename);
     await backupSave(targetPath);
    
  •  await File(targetPath).writeAsBytes(data);
    
  •  await io.File(targetPath).writeAsBytes(data);
     return true;
    
    } catch (e) { return false; diff --git a/lib/core/save/strategies/ppsspp_save_strategy.dart b/lib/core/save/strategies/ppsspp_save_strategy.dart index b62336f..b55afe6 100644 --- a/lib/core/save/strategies/ppsspp_save_strategy.dart +++ b/lib/core/save/strategies/ppsspp_save_strategy.dart @@ -1,6 +1,8 @@ -import 'dart:io'; -import 'dart:typed_data'; +import 'dart:io' as io; +import 'dart:io' show File; import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; import '../save_strategy.dart'; @@ -14,30 +16,38 @@ class PpssppSaveStrategy extends SaveStrategy { @override String get strategyId => 'ppsspp';
  • Future<String?> _getEmulatorDir() async {
  • final exePath = await _directoryService.findEmulatorExecutable(
  •    'ppsspp', 'PPSSPPWindows64.exe');
    
  • if (exePath == null) return null;
  • return File(exePath).parent.path.replaceAll('/', '\');
  • Future<String?> _getPspDir() async {
  • if (defaultTargetPlatform == TargetPlatform.macOS ||
  •    defaultTargetPlatform == TargetPlatform.linux) {
    
  •  final home = io.Platform.environment['HOME'];
    
  •  if (home == null) return null;
    
  •  return p.join(home, '.config', 'ppsspp', 'PSP');
    
  • } else {
  •  final exePath = await _directoryService.findEmulatorExecutable(
    
  •      'ppsspp', 'PPSSPPWindows64.exe');
    
  •  if (exePath == null) return null;
    
  •  final emuDir = io.File(exePath).parent.path;
    
  •  return p.join(emuDir, 'memstick', 'PSP');
    
  • } }

@override Future<String?> getSaveDir(Game game, String romPath) async {

  • final emuDir = await _getEmulatorDir();
  • if (emuDir == null) return null;
  • return '$emuDir\memstick\PSP\SAVEDATA';
  • final pspDir = await _getPspDir();
  • if (pspDir == null) return null;
  • return p.join(pspDir, 'SAVEDATA'); }

@override Future<List> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {

  • final emuDir = await _getEmulatorDir();
  • if (emuDir == null) return [];
  • final pspDir = await _getPspDir();

  • if (pspDir == null) return [];

    final result = []; final stem = getRomStem(game);

  • final saveDataDir = Directory('$emuDir\memstick\PSP\SAVEDATA');
  • final saveDataDir = io.Directory(p.join(pspDir, 'SAVEDATA')); if (await saveDataDir.exists()) { bool hasFiles = false; await for (final _ in saveDataDir.list(recursive: true)) { @@ -45,15 +55,16 @@ class PpssppSaveStrategy extends SaveStrategy { break; } if (hasFiles) {
  •    result.add(File(saveDataDir.path));
    
  •    result.add(io.File(saveDataDir.path));
     }
    
    }
  • final statesDir = Directory('$emuDir\memstick\PSP\PPSSPP_STATE');
  • final statesDir = io.Directory(p.join(pspDir, 'PPSSPP_STATE')); if (await statesDir.exists()) {
  •  final stateFile = File('${statesDir.path}\\$stem.ppst');
    
  •  final stateFile = io.File(p.join(pspDir, 'PPSSPP_STATE', '$stem.ppst'));
     if (await stateFile.exists()) {
    
  •    if (sessionStart == null || (await stateFile.stat()).modified.isAfter(sessionStart)) {
    
  •    if (sessionStart == null ||
    
  •        (await stateFile.stat()).modified.isAfter(sessionStart)) {
         result.add(stateFile);
       }
     }
    

@@ -66,35 +77,35 @@ class PpssppSaveStrategy extends SaveStrategy { Future restoreSave( Game game, String destPath, Uint8List data, String filename) async { try {

  •  final emuDir = await _getEmulatorDir();
    
  •  if (emuDir == null) return false;
    
  •  final pspDir = await _getPspDir();
    
  •  if (pspDir == null) return false;
    
     if (filename.toLowerCase().endsWith('.zip')) {
       final archive = ZipDecoder().decodeBytes(data);
    
  •    final targetBaseDir = '$emuDir\\memstick\\PSP\\SAVEDATA';
    
  •    final targetBaseDir = p.join(pspDir, 'SAVEDATA');
       for (final entry in archive) {
         if (entry.name.contains('.bak')) continue;
    
  •      final entryPath = entry.name.replaceAll('/', '\\');
    
  •      final segments = entryPath.split('\\');
    
  •      final strippedPath = segments.length > 1 ? segments.skip(1).join('\\') : entryPath;
    
  •      final segments = entry.name.split('/');
    
  •      final strippedPath =
    
  •          segments.length > 1 ? segments.skip(1).join('/') : entry.name;
         if (strippedPath.isEmpty) continue;
    
  •      final targetPath = '$targetBaseDir\\$strippedPath';
    
  •      final targetPath = p.join(targetBaseDir, strippedPath);
         if (entry.isFile) {
           await backupSave(targetPath);
    
  •        final outFile = File(targetPath);
    
  •        final outFile = io.File(targetPath);
           await outFile.parent.create(recursive: true);
           await outFile.writeAsBytes(entry.content as List<int>);
         } else {
    
  •        await Directory(targetPath).create(recursive: true);
    
  •        await io.Directory(targetPath).create(recursive: true);
         }
       }
       return true;
     }
    
     if (filename.toLowerCase().endsWith('.ppst')) {
    
  •    final targetPath = '$emuDir\\memstick\\PSP\\PPSSPP_STATE\\$filename';
    
  •    final targetPath = p.join(pspDir, 'PPSSPP_STATE', filename);
       await backupSave(targetPath);
    
  •    final outFile = File(targetPath);
    
  •    final outFile = io.File(targetPath);
       await outFile.parent.create(recursive: true);
       await outFile.writeAsBytes(data);
       return true;
    

diff --git a/lib/core/save/strategies/rpcs3_save_strategy.dart b/lib/core/save/strategies/rpcs3_save_strategy.dart index 4e22b1e..37737f6 100644 --- a/lib/core/save/strategies/rpcs3_save_strategy.dart +++ b/lib/core/save/strategies/rpcs3_save_strategy.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'dart:io' as io; import 'package:archive/archive_io.dart'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; import '../save_strategy.dart'; @@ -16,11 +18,17 @@ class Rpcs3SaveStrategy extends SaveStrategy { String get strategyId => 'rpcs3';

Future<String?> _getRpcs3SaveRoot() async {

  • if (io.Platform.isMacOS) {
  •  final home = io.Platform.environment['HOME'];
    
  •  if (home == null) return null;
    
  •  return p.join(home, 'Library', 'Application Support', 'rpcs3', 'dev_hdd0', 'home', '00000001', 'savedata');
    
  • }
  • final exePath = await _directoryService.findEmulatorExecutable( 'rpcs3', 'rpcs3.exe'); if (exePath == null) return null; final exeDir = File(exePath).parent.path;
  • return '$exeDir\dev_hdd0\home\00000001\savedata';
  • return p.join(exeDir, 'dev_hdd0', 'home', '00000001', 'savedata'); }

/// Finds all save folders for this game by scanning savedata dir for @@ -44,7 +52,7 @@ class Rpcs3SaveStrategy extends SaveStrategy { if (match != null) { final titleId = match.group(0)!; final byTitleId = allDirs.where((d) {

  •    final folderName = d.path.split('\\').last.toUpperCase();
    
  •    final folderName = d.path.split(p.separator).last.toUpperCase();
       return folderName.startsWith(titleId);
     }).toList();
     if (byTitleId.isNotEmpty) {
    

@@ -61,7 +69,7 @@ class Rpcs3SaveStrategy extends SaveStrategy { .toList();

 final byName = allDirs.where((d) {
  •  final folderLower = d.path.split('\\').last.toLowerCase();
    
  •  final folderLower = d.path.split(p.separator).last.toLowerCase();
     return gameWords.any((word) => folderLower.contains(word));
    
    }).toList();

@@ -107,7 +115,7 @@ class Rpcs3SaveStrategy extends SaveStrategy { } if (!hasFiles) return [];

  • final zipPath = '$saveRoot\${game.id}.saves.zip';
  • final zipPath = p.join(saveRoot, '${game.id}.saves.zip'); final encoder = ZipFileEncoder(); encoder.create(zipPath); for (final dir in saveDirs) { @@ -128,7 +136,7 @@ class Rpcs3SaveStrategy extends SaveStrategy { if (filename.toLowerCase().endsWith('.zip')) { final archive = ZipDecoder().decodeBytes(data); for (final entry in archive) {
  •      final entryPath = '$saveRoot\\${entry.name}';
    
  •      final entryPath = p.join(saveRoot, entry.name);
         if (entry.isFile) {
           await backupSave(entryPath);
           final outFile = File(entryPath);
    

@@ -144,7 +152,7 @@ class Rpcs3SaveStrategy extends SaveStrategy { // Fallback single file final saveDir = await getSaveDir(game, destPath) ?? saveRoot; await Directory(saveDir).create(recursive: true);

  •  final targetPath = '$saveDir\\$filename';
    
  •  final targetPath = p.join(saveDir, filename);
     await backupSave(targetPath);
     await File(targetPath).writeAsBytes(data);
     return true;
    

@@ -152,4 +160,4 @@ class Rpcs3SaveStrategy extends SaveStrategy { return false; } } -} \ No newline at end of file +} diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart index e71e873..a35e90d 100644 --- a/lib/core/storage/directory_service.dart +++ b/lib/core/storage/directory_service.dart @@ -1,14 +1,14 @@ -import 'dart:io'; +import 'dart:io' as io; +import 'dart:io' show Directory, File, Process; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:freegosy/core/romm/romm_models.dart';

class DirectoryService { static const String _romsRootPathKey = 'romsRootPath'; static const String _emulatorsRootPathKey = 'emulatorsRootPath';

  • static const String _defaultRomsPath = 'Documents/Freegosy/ROMs';

  • static const String _defaultEmulatorsPath = 'Documents/Freegosy/Emulators';

    // Known extensions per platform slug static const Map<String, List> _platformExtensions = { @@ -42,8 +42,18 @@ class DirectoryService {

    Future initialize() async { final prefs = await SharedPreferences.getInstance();

  • romsRootPath = prefs.getString(_romsRootPathKey) ?? _defaultRomsPath;

  • emulatorsRootPath = prefs.getString(_emulatorsRootPathKey) ?? _defaultEmulatorsPath;

  • final String defaultBase;
  • if (defaultTargetPlatform == TargetPlatform.macOS ||
  •    defaultTargetPlatform == TargetPlatform.linux) {
    
  •  final appSupport = await getApplicationSupportDirectory();
    
  •  defaultBase = appSupport.path;
    
  • } else {
  •  final docsDir = await getApplicationDocumentsDirectory();
    
  •  defaultBase = docsDir.path;
    
  • }
  • romsRootPath = prefs.getString(_romsRootPathKey) ?? '$defaultBase/ROMs';
  • emulatorsRootPath = prefs.getString(_emulatorsRootPathKey) ?? '$defaultBase/Emulators'; await _ensureDirectoryExists(romsRootPath); await _ensureDirectoryExists(emulatorsRootPath); await loadEmulatorPathOverrides(); @@ -164,17 +174,47 @@ class DirectoryService { return null; }
  • Future<String?> resolveSevenZipPath() async {
  • String appData = '';
  • Future<String?> _getWindowsAppData() async {

  • try {

  •  final result = await Process.run('cmd', ['/c', 'echo %APPDATA%'], runInShell: false);
    
  •  final path = result.stdout.toString().trim();
    
  •  if (path.isEmpty || path.contains('%APPDATA%')) return null;
    
  •  return path;
    
  • } catch (e) {

  •  return null;
    
  • }

  • }

  • Future<String?> resolveSevenZipPath() async { if (defaultTargetPlatform == TargetPlatform.windows) {

  •  final appData = await _getWindowsAppData();
    
  •  if (appData == null) return null;
    
  •  final dest = File('$appData\\Freegosy\\thirdparty\\7zr.exe');
    
  •  if (await dest.exists()) return dest.path;
    
  •  try {
    
  •    await dest.parent.create(recursive: true);
    
  •    final byteData = await rootBundle.load('thirdparty/7zr.exe');
    
  •    await dest.writeAsBytes(byteData.buffer.asUint8List());
    
  •    return dest.path;
    
  •  } catch (e) {
    
  •    return null;
    
  •  }
    
  • } else if (defaultTargetPlatform == TargetPlatform.macOS) {

  •  final appSupport = await getApplicationSupportDirectory();
    
  •  final dest = File('${appSupport.path}/Freegosy/thirdparty/7zz');
    
  •  if (await dest.exists()) return dest.path;
    
  •  try {
    
  •    final result = await Process.run('cmd', ['/c', 'echo %APPDATA%'], runInShell: false);
    
  •    appData = result.stdout.toString().trim();
    
  •    await dest.parent.create(recursive: true);
    
  •    final byteData = await rootBundle.load('thirdparty/7zz');
    
  •    await dest.writeAsBytes(byteData.buffer.asUint8List());
    
  •    await Process.run('chmod', ['+x', dest.path]);
    
  •    return dest.path;
     } catch (e) {
       return null;
     }
    
  •  if (appData.isEmpty || appData.contains('%APPDATA%')) return null;
    

    } else if (defaultTargetPlatform == TargetPlatform.linux) { // Linux: Try to find system 7z try { @@ -186,23 +226,9 @@ class DirectoryService { // ignore } return null;

  • } else {

  •  // macOS: no 7zr needed
    
  •  return null;
    

    }

  • final dest = File('$appData\Freegosy\thirdparty\7zr.exe');

  • if (await dest.exists()) return dest.path;

  • try {

  •  await dest.parent.create(recursive: true);
    
  •  final byteData = await rootBundle.load('thirdparty/7zr.exe');
    
  •  await dest.writeAsBytes(byteData.buffer.asUint8List());
    
  •  return dest.path;
    
  • } catch (e) {

  •  return null;
    
  • }

  • return null; }

Future getEmulatorDirectory(String emulatorId) async { @@ -214,39 +240,187 @@ class DirectoryService { return dirPath; }

  • Future getEmulatorSystemDirectory(String emulatorId) async {
  • String dirPath;
  • if (emulatorId == 'retroarch') {
  •  final emuDir = await getEmulatorDirectory(emulatorId);
    
  •  dirPath = '$emuDir/system';
    
  • } else if (emulatorId == 'azahar') {
  •  if (io.Platform.isMacOS) {
    
  •    final home = io.Platform.environment['HOME'];
    
  •    if (home == null) throw Exception('HOME environment variable not set');
    
  •    dirPath = '$home/Library/Application Support/Azahar';
    
  •  } else if (io.Platform.isWindows) {
    
  •    final appData = await _getWindowsAppData();
    
  •    if (appData == null) throw Exception('APPDATA environment variable not set');
    
  •    dirPath = '$appData\\Azahar';
    
  •  } else {
    
  •    dirPath = await getEmulatorDirectory(emulatorId);
    
  •  }
    
  • } else if (emulatorId == 'pcsx2') {
  •  if (io.Platform.isMacOS) {
    
  •    final home = io.Platform.environment['HOME'];
    
  •    if (home == null) throw Exception('HOME environment variable not set');
    
  •    dirPath = '$home/Library/Application Support/PCSX2';
    
  •  } else if (io.Platform.isWindows) {
    
  •    final appData = await _getWindowsAppData();
    
  •    if (appData == null) throw Exception('APPDATA environment variable not set');
    
  •    dirPath = '$appData\\PCSX2';
    
  •  } else {
    
  •    dirPath = await getEmulatorDirectory(emulatorId);
    
  •  }
    
  • } else {
  •  dirPath = await getEmulatorDirectory(emulatorId);
    
  • }
  • await _ensureDirectoryExists(dirPath);
  • return dirPath;
  • }
  • Future deleteEmulator(String emulatorId) async {
  • final dirPath = await getEmulatorDirectory(emulatorId);
  • final directory = io.Directory(dirPath);
  • if (await directory.exists()) {
  •  await directory.delete(recursive: true);
    
  • }
  • }
  • Future getEmulatorExecutable(String emulatorId, String executableName) async { final emulatorDir = await getEmulatorDirectory(emulatorId); return '$emulatorDir/$executableName'; }
  • Future isEmulatorInstalled(String emulatorId, String executableName) async {
  • final dir = Directory(await getEmulatorDirectory(emulatorId));
  • if (!await dir.exists()) return false;
  • if (await File('${dir.path}/$executableName').exists()) return true;
  • await for (final entity in dir.list()) {
  •  if (entity is Directory) {
    
  •    if (await File('${entity.path}/$executableName').exists()) return true;
    
  • Future<String?> findEmulatorExecutable(String emulatorId, String executableName) async {
  • final emulatorDir = await getEmulatorDirectory(emulatorId);
  • final dir = Directory(emulatorDir);
  • if (!await dir.exists()) {
  •  return null;
    
  • }
  • debugPrint("=== EMULATOR CHECK ===");
  • debugPrint("Looking for: $executableName in $emulatorDir");
  • await for (final e in dir.list()) {
  •  debugPrint("Found on disk: ${e.path}");
    
  • }
  • final direct = File('$emulatorDir/$executableName');
  • if (await direct.exists()) {
  •  return direct.path;
    
  • }
  • if (executableName.contains('/')) {
  •  final parts = executableName.split('/');
    
  •  final firstPart = parts.first; // e.g. "PCSX2.app"
    
  •  final remaining = parts.sublist(1).join('/'); // e.g. "Contents/MacOS/PCSX2"
    
  •  // Recursive search for the bundle directory (max 3 levels)
    
  •  await for (final entity in dir.list(recursive: true)) {
    
  •    if (entity is Directory) {
    
  •      final dirName = entity.path.split(io.Platform.isWindows ? r'\' : '/').last;
    
  •      // Exact match or fuzzy match for versioned .app bundles (e.g. PCSX2-v2.6.3.app matches PCSX2.app)
    
  •      bool matches = dirName.toLowerCase() == firstPart.toLowerCase();
    
  •      if (!matches && firstPart.toLowerCase().endsWith('.app') && dirName.toLowerCase().endsWith('.app')) {
    
  •        final stem = firstPart.substring(0, firstPart.length - 4).toLowerCase();
    
  •        if (dirName.toLowerCase().startsWith(stem)) {
    
  •          matches = true;
    
  •        }
    
  •      }
    
  •      if (matches) {
    
  •        debugPrint("[DirectoryService] Found matching .app bundle: $dirName at ${entity.path}");
    
  •        final sub = File('${entity.path}/$remaining');
    
  •        if (await sub.exists()) {
    
  •          debugPrint("[DirectoryService] Found direct binary: ${sub.path}");
    
  •          return sub.path;
    
  •        }
    
  •        // Secondary check: search inside Contents/MacOS case-insensitively
    
  •        final macosDir = Directory('${entity.path}/Contents/MacOS');
    
  •        if (await macosDir.exists()) {
    
  •          final binaryName = remaining.split('/').last.toLowerCase();
    
  •          debugPrint("[DirectoryService] Searching for binary '$binaryName' in ${macosDir.path}");
    
  •          await for (final subEntity in macosDir.list()) {
    
  •            if (subEntity is File) {
    
  •              final subName = subEntity.path.split('/').last.toLowerCase();
    
  •              if (subName == binaryName) {
    
  •                debugPrint("[DirectoryService] Found binary via case-insensitive match: ${subEntity.path}");
    
  •                return subEntity.path;
    
  •              }
    
  •            }
    
  •          }
    
  •          // Third check: find any file in Contents/MacOS (fallback)
    
  •          await for (final subEntity in macosDir.list()) {
    
  •            if (subEntity is File) {
    
  •              final subName = subEntity.path.split('/').last.toLowerCase();
    
  •              final stem = dirName.toLowerCase().replaceAll('.app', '');
    
  •              if (subName.contains(stem) || stem.contains(subName)) {
    
  •                debugPrint("[DirectoryService] Found binary via stem match: ${subEntity.path}");
    
  •                return subEntity.path;
    
  •              }
    
  •            }
    
  •          }
    
  •          // Last resort: return the first file in Contents/MacOS
    
  •          await for (final subEntity in macosDir.list()) {
    
  •            if (subEntity is File) {
    
  •               debugPrint("[DirectoryService] Using first binary found as last resort: ${subEntity.path}");
    
  •               return subEntity.path;
    
  •            }
    
  •          }
    
  •        }
    
  •      }
    
  •    }
     }
    
  •  return null;
    
    }
  • return false;

  • }

  • Future<String?> findEmulatorExecutable(String emulatorId, String executableName) async {

  • final dir = Directory(await getEmulatorDirectory(emulatorId));

  • if (!await dir.exists()) return null;

  • final direct = File('${dir.path}/$executableName');

  • if (await direct.exists()) return direct.path; await for (final entity in dir.list()) {

  •  if (entity is File && entity.path.endsWith('/$executableName')) {
    
  •    return entity.path;
    
  •  }
     if (entity is Directory) {
       final sub = File('${entity.path}/$executableName');
    
  •    if (await sub.exists()) return sub.path;
    
  •    if (await sub.exists()) {
    
  •      return sub.path;
    
  •    }
    
  •  }
    
  • }

  • // Secondary check for core libraries if binary not found

  • final ext = io.Platform.isMacOS ? '.dylib' : (io.Platform.isWindows ? '.dll' : '.so');

  • await for (final entity in dir.list(recursive: true)) {

  •  if (entity is File) {
    
  •    final name = entity.path.split(io.Platform.isWindows ? r'\' : '/').last.toLowerCase();
    
  •    if (name.endsWith(ext) && name.contains(emulatorId.toLowerCase())) {
    
  •      debugPrint("Found core library fallback: ${entity.path}");
    
  •      return entity.path;
    
  •    }
     }
    

    }

  • return null; }

  • Future isEmulatorInstalled(String emulatorId, String executableName) async {

  • final found = await findEmulatorExecutable(emulatorId, executableName);

  • return found != null;

  • }

  • Future isRomDownloaded(Game game) async { final found = await findExistingRomPath(game); return found != null; }

  • Future deleteRom(Game game) async {

  • final path = await findExistingRomPath(game);

  • if (path != null) {

  •  if (await File(path).exists()) {
    
  •    await File(path).delete();
    
  •  } else if (await Directory(path).exists()) {
    
  •    await Directory(path).delete(recursive: true);
    
  •  }
    
  • }

  • } } \ No newline at end of file diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart index e113520..7be38d1 100644 --- a/lib/providers/library_provider.dart +++ b/lib/providers/library_provider.dart @@ -5,7 +5,7 @@ import '../core/romm/romm_models.dart'; import 'romm_provider.dart';

const String _gamesCacheKey = 'cached_games'; -const String _platformsCacheKey = 'cached_platforms'; +const String _platformsCacheKey = 'cached_platforms_v2'; const String _gamesCacheTimeKey = 'cached_games_time'; const String _platformsCacheTimeKey = 'cached_platforms_time'; const int _cacheExpiryDays = 7; @@ -64,6 +64,7 @@ Future _savePlatformsCache(List platforms) async { 'id': p.id, 'name': p.name, 'slug': p.slug,

  • 'games_count': p.gamesCount, }).toList(); await prefs.setString(_platformsCacheKey, jsonEncode(jsonList)); await prefs.setString(platformsCacheTimeKey, DateTime.now().toIso8601String()); @@ -236,7 +237,7 @@ final platformsProvider = FutureProvider<List>((ref) async { } } catch () {} });
  • return cached;
  • return cached.where((p) => p.gamesCount > 0).toList(); }

// No valid cache — fetch fresh @@ -244,7 +245,7 @@ final platformsProvider = FutureProvider<List>((ref) async { if (platforms.isNotEmpty) { await _savePlatformsCache(platforms); }

  • return platforms;
  • return platforms.where((p) => p.gamesCount > 0).toList(); });

final allGamesProvider = FutureProvider<List>((ref) async { diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index 51a699a..04dc644 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -4,12 +4,16 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../providers/library_provider.dart'; import '../../providers/download_provider.dart'; import '../../providers/romm_provider.dart'; import '../../providers/paginated_games_provider.dart'; import '../../core/storage/directory_service.dart'; import '../../core/romm/romm_models.dart'; +import '../../core/save/save_strategy.dart'; +import '../../core/save/strategies/eden_save_strategy.dart'; +import '../../core/save/strategies/azahar_save_strategy.dart'; import '../../core/emulator/strategies/windows_strategy.dart'; import '../../core/emulator/strategies/retroarch_strategy.dart'; import '../widgets/game_card.dart'; @@ -40,6 +44,7 @@ class _LibraryScreenState extends ConsumerState { _scrollController.addListener(onScroll); WidgetsBinding.instance.addPostFrameCallback(() { _focusNode.requestFocus();

  •  _refreshAllDownloadStates();
    
    }); }

@@ -75,6 +80,24 @@ class _LibraryScreenState extends ConsumerState { platformId: ref.read(selectedPlatformIdProvider)?.toString(), search: ref.read(searchQueryProvider).isEmpty ? null : ref.read(searchQueryProvider), );

  • await _refreshAllDownloadStates();
  • }
  • Future _refreshAllDownloadStates() async {
  • if (ref.read(paginatedGamesProvider).games.isEmpty) return;
  • final dirService = ref.read(directoryServiceProvider).asData?.value;
  • if (dirService == null) return;
  • final games = ref.read(paginatedGamesProvider).games;
  • final results = await Future.wait(
  •  games.map((g) async => MapEntry(g.id, await dirService.isRomDownloaded(g))),
    
  • );
  • if (mounted) {
  •  setState(() {
    
  •    for (final entry in results) {
    
  •      _downloadedStates[entry.key] = entry.value;
    
  •    }
    
  •  });
    
  • } }

void _startDownload(BuildContext context, WidgetRef ref, Game game) { @@ -107,6 +130,9 @@ class _LibraryScreenState extends ConsumerState { if (registryReady == null) return; final strategy = registryReady.getStrategyForSlug(game.platformSlug ?? '');

  • debugPrint("=== LAUNCH DEBUG ===");
  • debugPrint("Game: ${game.name} | Slug: ${game.platformSlug} | Chosen Strategy: ${strategy?.name} (ID: ${strategy?.emulatorId})");
  • if (strategy == null) { messenger.showSnackBar( SnackBar( @@ -186,7 +212,33 @@ class _LibraryScreenState extends ConsumerState { duration: const Duration(seconds: 30), ), );
  •  await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
    
  •  try {
    
  •    await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
    
  •  } on SaveMappingRequiredException {
    
  •    if (!context.mounted) return;
    
  •    final strategy = syncService.getStrategyForSlug(game.platformSlug);
    
  •    if (strategy is EdenSaveStrategy || strategy is AzaharSaveStrategy) {
    
  •      final selectedFolder = await _showFolderMappingDialog(context, strategy);
    
  •      if (selectedFolder != null) {
    
  •        await syncService.saveMappedFolder(game.id, selectedFolder);
    
  •        if (context.mounted) {
    
  •          await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
    
  •        }
    
  •      }
    
  •    }
    
  •  } on ProfileConflictException catch (e) {
    
  •    if (!context.mounted) return;
    
  •    final selectedProfile = await _showProfileConflictDialog(context, e.profiles);
    
  •    if (selectedProfile != null) {
    
  •      await syncService.saveActiveProfile(selectedProfile);
    
  •      if (context.mounted) {
    
  •        await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
    
  •      }
    
  •    }
    
  •  } catch (e) {
    
  •    // Ignore other push errors during launch to not block playing
    
  •  }
    
  •  if (!context.mounted) return;
     messenger.clearSnackBars();
     try {
    

@@ -199,6 +251,27 @@ class _LibraryScreenState extends ConsumerState { duration: Duration(seconds: 2)), ); }

  •  } on SaveMappingRequiredException {
    
  •    if (!context.mounted) return;
    
  •    final strategy = syncService.getStrategyForSlug(game.platformSlug);
    
  •    if (strategy is EdenSaveStrategy || strategy is AzaharSaveStrategy) {
    
  •      final selectedFolder = await _showFolderMappingDialog(context, strategy);
    
  •      if (selectedFolder != null) {
    
  •        await syncService.saveMappedFolder(game.id, selectedFolder);
    
  •        if (context.mounted) {
    
  •          await syncService.pullSave(game, existingRomPath);
    
  •        }
    
  •      }
    
  •    }
    
  •  } on ProfileConflictException catch (e) {
    
  •    if (!context.mounted) return;
    
  •    final selectedProfile = await _showProfileConflictDialog(context, e.profiles);
    
  •    if (selectedProfile != null) {
    
  •      await syncService.saveActiveProfile(selectedProfile);
    
  •      if (context.mounted) {
    
  •        await syncService.pullSave(game, existingRomPath);
    
  •      }
    
  •    }
     } catch (e) {
       if (!context.mounted) return;
       final shouldContinue = await showDialog<bool>(
    

@@ -224,6 +297,39 @@ class _LibraryScreenState extends ConsumerState { } }

  • // Check for 3DS keys before starting
  • final is3ds = [
  •  '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds',
    
  •  'new-nintendo-3ds', 'new-nintendo-3ds-xl'
    
  • ].contains(game.platformSlug?.toLowerCase());
  • if (is3ds) {
  •  final systemDir = await dir.getEmulatorSystemDirectory(strategy.emulatorId);
    
  •  final keysSubPath = strategy.emulatorId == 'retroarch' ? 'citra/sysdata/aes_keys.txt' : 'sysdata/aes_keys.txt';
    
  •  final keysPath = '$systemDir/$keysSubPath';
    
  •  if (!await File(keysPath).exists()) {
    
  •    final prefs = await SharedPreferences.getInstance();
    
  •    final shownOnce = prefs.getBool('shown_3ds_keys_warning') ?? false;
    
  •    if (!shownOnce && context.mounted) {
    
  •      await showDialog(
    
  •        context: context,
    
  •        builder: (ctx) => AlertDialog(
    
  •          title: const Text('Missing 3DS Keys'),
    
  •          content: const Text('Note: Decrypted 3DS ROMs require aes_keys.txt in your emulator system folder to run correctly.'),
    
  •          actions: [
    
  •            TextButton(
    
  •              onPressed: () => Navigator.of(ctx).pop(),
    
  •              child: const Text('OK'),
    
  •            ),
    
  •          ],
    
  •        ),
    
  •      );
    
  •      await prefs.setBool('shown_3ds_keys_warning', true);
    
  •    }
    
  •  }
    
  • }
  • try { messenger.showSnackBar( SnackBar( @@ -362,31 +468,283 @@ class _LibraryScreenState extends ConsumerState { await _handleLaunch(context, ref, game); }
  • Future _handleSyncSaves(
  •  BuildContext context, WidgetRef ref, Game game) async {
    
  • Future _handleDeleteRom(BuildContext context, WidgetRef ref, Game game) async { final messenger = ScaffoldMessenger.of(context);

  • final dirService = ref.read(directoryServiceProvider).asData?.value;

  • if (dirService == null) return;

  • try {

  •  await dirService.deleteRom(game);
    
  •  _refreshDownloadState(dirService, game);
    
  •  messenger.showSnackBar(SnackBar(content: Text('Deleted local files for ${game.name}')));
    
  • } catch (e) {

  •  messenger.showSnackBar(SnackBar(content: Text('Failed to delete files: $e')));
    
  • }

  • }

  • Future _handlePushSaves(

  •  BuildContext context, WidgetRef ref, Game game) async {
    
  • final messenger = ScaffoldMessenger.of(context); final syncService = await ref.read(saveSyncServiceProvider.future); if (syncService == null) {

  •  messenger.showSnackBar(
    
  •    const SnackBar(content: Text('Save sync not available')),
    
  •  );
    
  •  messenger.showSnackBar(const SnackBar(content: Text('Save sync not available')));
     return;
    
    }
  • final dir = ref.read(directoryServiceProvider).asData?.value; final romPath = dir != null ? await dir.getRomFilePath(game) : ''; if (!context.mounted) return;
  • messenger.showSnackBar(
  •  SnackBar(content: Text('Syncing saves for ${game.name}...')),
    
  • ); final syncMode = ref.read(retroarchSyncModeProvider);
  • final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode);
  • try {
  •  messenger.showSnackBar(SnackBar(content: Text('Uploading saves for ${game.name}...')));
    
  •  final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
    
  •  if (!context.mounted) return;
    
  •  messenger.showSnackBar(SnackBar(content: Text(ok ? 'Saves uploaded' : 'No local saves found')));
    
  • } on SaveMappingRequiredException {
  •  if (!context.mounted) return;
    
  •  final strategy = syncService.getStrategyForSlug(game.platformSlug);
    
  •  final selectedFolder = await _showFolderMappingDialog(context, strategy);
    
  •  if (selectedFolder != null) {
    
  •    await syncService.saveMappedFolder(game.id, selectedFolder);
    
  •    if (!context.mounted) return;
    
  •    // Retry
    
  •    return _handlePushSaves(context, ref, game);
    
  •  }
    
  • } on ProfileConflictException catch (e) {
  •  if (!context.mounted) return;
    
  •  final selectedProfile = await _showProfileConflictDialog(context, e.profiles);
    
  •  if (selectedProfile != null) {
    
  •    await syncService.saveActiveProfile(selectedProfile);
    
  •    if (!context.mounted) return;
    
  •    return _handlePushSaves(context, ref, game);
    
  •  }
    
  • } catch (e) {
  •  if (!context.mounted) return;
    
  •  messenger.showSnackBar(SnackBar(content: Text('Error: $e')));
    
  • }
  • }
  • Future _handlePullSaves(
  •  BuildContext context, WidgetRef ref, Game game) async {
    
  • final messenger = ScaffoldMessenger.of(context);
  • final syncService = await ref.read(saveSyncServiceProvider.future);
  • if (syncService == null) {
  •  messenger.showSnackBar(const SnackBar(content: Text('Save sync not available')));
    
  •  return;
    
  • }
  • final dir = ref.read(directoryServiceProvider).asData?.value;
  • final romPath = dir != null ? await dir.getRomFilePath(game) : ''; if (!context.mounted) return;
  • messenger.showSnackBar(
  •  SnackBar(
    
  •    content: Text(ok
    
  •        ? 'Saves uploaded for ${game.name}'
    
  •        : 'No saves found for ${game.name}'),
    
  • try {
  •  messenger.showSnackBar(SnackBar(content: Text('Fetching cloud saves for ${game.name}...')));
    
  •  final saves = await syncService.getSavesForGame(game.id);
    
  •  if (!context.mounted) return;
    
  •  if (saves.isEmpty) {
    
  •    messenger.showSnackBar(const SnackBar(content: Text('No cloud saves found for this game.')));
    
  •    return;
    
  •  }
    
  •  final selectedSave = await _showSaveSelectionDialog(context, saves);
    
  •  if (selectedSave == null) return;
    
  •  if (!context.mounted) return;
    
  •  messenger.showSnackBar(SnackBar(content: Text('Downloading selected save...')));
    
  •  final ok = await syncService.pullSave(game, romPath, saveData: selectedSave);
    
  •  if (!context.mounted) return;
    
  •  if (ok) {
    
  •    messenger.showSnackBar(const SnackBar(content: Text('Saves downloaded')));
    
  •  } else {
    
  •    messenger.showSnackBar(const SnackBar(content: Text('Save appears unchanged since last pull. Retrying with force...')));
    
  •    // Force pull by clearing the pull timestamp
    
  •    final prefs = await SharedPreferences.getInstance();
    
  •    await prefs.remove('last_pull_${game.id}');
    
  •    if (context.mounted) {
    
  •      final retryOk = await syncService.pullSave(game, romPath, saveData: selectedSave);
    
  •      if (context.mounted) {
    
  •        messenger.showSnackBar(SnackBar(content: Text(retryOk ? 'Saves downloaded' : 'Failed to download save')));
    
  •      }
    
  •    }
    
  •  }
    
  • } on SaveMappingRequiredException {
  •  if (!context.mounted) return;
    
  •  final strategy = syncService.getStrategyForSlug(game.platformSlug);
    
  •  final selectedFolder = await _showFolderMappingDialog(context, strategy);
    
  •  if (selectedFolder != null) {
    
  •    await syncService.saveMappedFolder(game.id, selectedFolder);
    
  •    if (!context.mounted) return;
    
  •    // Retry
    
  •    return _handlePullSaves(context, ref, game);
    
  •  }
    
  • } on ProfileConflictException catch (e) {
  •  if (!context.mounted) return;
    
  •  final selectedProfile = await _showProfileConflictDialog(context, e.profiles);
    
  •  if (selectedProfile != null) {
    
  •    await syncService.saveActiveProfile(selectedProfile);
    
  •    if (!context.mounted) return;
    
  •    return _handlePullSaves(context, ref, game);
    
  •  }
    
  • } catch (e) {
  •  if (!context.mounted) return;
    
  •  messenger.showSnackBar(SnackBar(content: Text('Error: $e')));
    
  • }
  • }
  • Future<Map<String, dynamic>?> _showSaveSelectionDialog(BuildContext context, List<Map<String, dynamic>> saves) async {
  • return showDialog<Map<String, dynamic>>(
  •  context: context,
    
  •  builder: (context) => AlertDialog(
    
  •    title: const Text('Select Cloud Save'),
    
  •    content: SizedBox(
    
  •      width: double.maxFinite,
    
  •      child: ListView.builder(
    
  •        shrinkWrap: true,
    
  •        itemCount: saves.length,
    
  •        itemBuilder: (context, index) {
    
  •          final save = saves[index];
    
  •          final fileName = save['file_name_no_ext'] ?? save['file_name'] ?? 'Unknown Save';
    
  •          final createdAtStr = save['created_at'] ?? save['updated_at'] ?? '';
    
  •          final createdAt = DateTime.tryParse(createdAtStr.toString());
    
  •          String subtitle = 'Unknown date';
    
  •          if (createdAt != null) {
    
  •            final diff = DateTime.now().difference(createdAt);
    
  •            if (diff.inDays > 0) {
    
  •              subtitle = '${diff.inDays}d ago';
    
  •            } else if (diff.inHours > 0) {
    
  •              subtitle = '${diff.inHours}h ago';
    
  •            } else if (diff.inMinutes > 0) {
    
  •              subtitle = '${diff.inMinutes}m ago';
    
  •            } else {
    
  •              subtitle = 'just now';
    
  •            }
    
  •          }
    
  •          return ListTile(
    
  •            title: Text(fileName.toString()),
    
  •            subtitle: Text(subtitle),
    
  •            onTap: () => Navigator.pop(context, save),
    
  •          );
    
  •        },
    
  •      ),
    
  •    ),
    
  •    actions: [
    
  •      TextButton(
    
  •        onPressed: () => Navigator.pop(context),
    
  •        child: const Text('Cancel'),
    
  •      ),
    
  •    ],
    
  •  ),
    
  • );
  • }
  • Future<String?> _showProfileConflictDialog(BuildContext context, List<Map<String, dynamic>> profiles) async {
  • return showDialog(
  •  context: context,
    
  •  builder: (context) => AlertDialog(
    
  •    title: const Text('Multiple Profiles Detected'),
    
  •    content: SizedBox(
    
  •      width: double.maxFinite,
    
  •      child: Column(
    
  •        mainAxisSize: MainAxisSize.min,
    
  •        children: [
    
  •          const Text('Multiple Eden profiles have recent save activity. Which one would you like to use?'),
    
  •          const SizedBox(height: 16),
    
  •          Flexible(
    
  •            child: ListView.builder(
    
  •              shrinkWrap: true,
    
  •              itemCount: profiles.length,
    
  •              itemBuilder: (context, index) {
    
  •                final profile = profiles[index];
    
  •                final id = profile['id'] as String;
    
  •                final lastActive = profile['lastActive'] as DateTime;
    
  •                final diff = DateTime.now().difference(lastActive);
    
  •                String timeAgo;
    
  •                if (diff.inDays > 0) {
    
  •                  timeAgo = '${diff.inDays}d ago';
    
  •                } else if (diff.inHours > 0) {
    
  •                  timeAgo = '${diff.inHours}h ago';
    
  •                } else if (diff.inMinutes > 0) {
    
  •                  timeAgo = '${diff.inMinutes}m ago';
    
  •                } else {
    
  •                  timeAgo = 'just now';
    
  •                }
    
  •                return ListTile(
    
  •                  title: Text(id),
    
  •                  subtitle: Text('Last active: $timeAgo'),
    
  •                  onTap: () => Navigator.pop(context, id),
    
  •                );
    
  •              },
    
  •            ),
    
  •          ),
    
  •        ],
    
  •      ),
    
  •    ),
    
  •    actions: [
    
  •      TextButton(
    
  •        onPressed: () => Navigator.pop(context),
    
  •        child: const Text('Cancel'),
    
  •      ),
    
  •    ],
    
  •  ),
    
  • );
  • }
  • Future<String?> _showFolderMappingDialog(BuildContext context, dynamic strategy) async {
  • final folders = await strategy.getAvailableSaveFolders();
  • if (!context.mounted) return null;
  • return showDialog(
  •  context: context,
    
  •  builder: (context) => AlertDialog(
    
  •    title: const Text('Select Save Folder'),
    
  •    content: SizedBox(
    
  •      width: double.maxFinite,
    
  •      child: folders.isEmpty
    
  •          ? const Text('No saves found. Launch the game in the emulator first.')
    
  •          : ListView.builder(
    
  •              shrinkWrap: true,
    
  •              itemCount: folders.length,
    
  •              itemBuilder: (context, index) {
    
  •                final folder = folders[index];
    
  •                final name = folder['name'] as String;
    
  •                final path = (folder['path'] ?? folder['name']) as String;
    
  •                final date = folder['lastModified'] as DateTime;
    
  •                // Simple relative time string
    
  •                final diff = DateTime.now().difference(date);
    
  •                String timeAgo;
    
  •                if (diff.inDays > 0) {
    
  •                  timeAgo = '${diff.inDays}d ago';
    
  •                } else if (diff.inHours > 0) {
    
  •                  timeAgo = '${diff.inHours}h ago';
    
  •                } else if (diff.inMinutes > 0) {
    
  •                  timeAgo = '${diff.inMinutes}m ago';
    
  •                } else {
    
  •                  timeAgo = 'just now';
    
  •                }
    
  •                return ListTile(
    
  •                  title: Text(name),
    
  •                  subtitle: Text('Last played: $timeAgo'),
    
  •                  onTap: () => Navigator.pop(context, path),
    
  •                );
    
  •              },
    
  •            ),
    
  •    ),
    
  •    actions: [
    
  •      TextButton(
    
  •        onPressed: () => Navigator.pop(context),
    
  •        child: const Text('Cancel'),
    
  •      ),
    
  •    ],
     ),
    
    ); } @@ -522,6 +880,11 @@ class _LibraryScreenState extends ConsumerState { )); } final game = paginatedState.games[index];
  •                                  if (_downloadedStates[game.id] == null) {
    
  •                                    WidgetsBinding.instance.addPostFrameCallback((_) {
    
  •                                      _refreshAllDownloadStates();
    
  •                                    });
    
  •                                  }
                                     final dirService = directoryServiceAsync.asData?.value;
                                     final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
                                     final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
    

@@ -533,9 +896,10 @@ class _LibraryScreenState extends ConsumerState { showButtonsOnHover: showButtonsOnHover, onDownload: () => _startDownload(context, ref, game), onLaunch: () => _handleLaunch(context, ref, game),

  •                                        onSyncSaves: () => _handleSyncSaves(context, ref, game),
    
  •                                      ),
    
  •                                    );
    
  •                                        onDelete: () => _handleDeleteRom(context, ref, game),
    
  •                                        onPushSaves: () => _handlePushSaves(context, ref, game),
    
  •                                        onPullSaves: () => _handlePullSaves(context, ref, game),
    
  •                                      ),                                        );
                                     }
                                     return GestureDetector(
                                       onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null,
    

@@ -545,7 +909,9 @@ class _LibraryScreenState extends ConsumerState { showTitle: showTitle, showButtonsOnHover: showButtonsOnHover, onDownload: () => _startDownload(context, ref, game), onLaunch: () => _handleLaunch(context, ref, game),

  •                                      onSyncSaves: () => _handleSyncSaves(context, ref, game),
    
  •                                      onDelete: () => _handleDeleteRom(context, ref, game),
    
  •                                      onPushSaves: () => _handlePushSaves(context, ref, game),
    
  •                                      onPullSaves: () => _handlePullSaves(context, ref, game),
                                       ),
                                     );
                                   },
    

diff --git a/lib/ui/screens/settings_emulators_section.dart b/lib/ui/screens/settings_emulators_section.dart index 4fc673a..c6e27a5 100644 --- a/lib/ui/screens/settings_emulators_section.dart +++ b/lib/ui/screens/settings_emulators_section.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; @@ -5,6 +7,7 @@ import '../../core/storage/directory_service.dart'; import '../../core/emulator/emulator_registry_data.dart'; import '../../core/emulator/strategy_registry.dart'; import '../../providers/download_provider.dart'; +import '../../providers/romm_provider.dart';

// Function to build the Emulators section Widget buildEmulatorsSection( @@ -15,6 +18,16 @@ Widget buildEmulatorsSection( Function(void Function()) setState, WidgetRef ref, ) {

  • final currentPlatform = defaultTargetPlatform == TargetPlatform.windows
  •  ? 'windows' 
    
  •  : (defaultTargetPlatform == TargetPlatform.macOS ? 'macos' : 'linux');
    
  • // Filter emulators that are supported on the current OS
  • final supportedEmulators = kEmulatorDefinitions.where((def) {
  • final supported = def['supported_platforms'] as List? ?? [];
  • return supported.contains(currentPlatform);
  • }).toList();
  • return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -24,11 +37,13 @@ Widget buildEmulatorsSection( if (!emulatorsLoaded) const Center(child: CircularProgressIndicator()) else
  •    ...kEmulatorDefinitions.map<Widget>((def) {
    
  •    ...supportedEmulators.map<Widget>((def) {
         final emulatorId = def['id'] as String;
         final emulatorName = def['name'] as String;
         final isInstalled = emulatorInstallStates[emulatorId] ?? false;
         final overridePath = directoryService.getEmulatorPathOverride(emulatorId);
    
  •      final type = def['type'] as String? ?? 'none';
    
  •      final canManage = type != 'none'; // Only github/direct emulators can be updated/uninstalled
    
         return Padding(
           padding: const EdgeInsets.symmetric(vertical: 8.0),
    

@@ -64,34 +79,74 @@ Widget buildEmulatorsSection( } }, ),

  •            ElevatedButton(
    
  •              onPressed: isInstalled
    
  •                  ? null
    
  •                  : () async {
    
  •                      // Capture context safely before any async operations.
    
  •                      final messenger = ScaffoldMessenger.of(context);
    
  •                      messenger.showSnackBar(SnackBar(
    
  •                        content: Text('Starting download for $emulatorName...'),
    
  •                      ));
    
  •                      ref.read(downloadProvider.notifier).startEmulatorDownload(emulatorId, emulatorName);
    
  •            if (isInstalled) ...[
    
  •              IconButton(
    
  •                icon: const Icon(Icons.play_arrow, color: Colors.green),
    
  •                tooltip: "Launch Standalone",
    
  •                onPressed: () async {
    
  •                  final messenger = ScaffoldMessenger.of(context);
    
  •                  final strategy = ref
    
  •                      .read(strategyRegistryProvider)
    
  •                      .asData
    
  •                      ?.value
    
  •                      ?.getStrategyById(emulatorId);
    
  •                  if (strategy != null) {
    
  •                    try {
    
  •                      await strategy.launchStandalone();
    
  •                    } catch (e) {
    
  •                      messenger.showSnackBar(
    
  •                          SnackBar(content: Text("Failed to launch: $e")));
    
  •                    }
    
  •                  }
    
  •                },
    
  •              ),
    
  •              if (canManage) ...[
    
  •                IconButton(
    
  •                  icon: const Icon(Icons.update, color: Colors.blue),
    
  •                  tooltip: "Update Emulator",
    
  •                  onPressed: () async {
    
  •                    _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState);
    
  •                  },
    
  •                ),
    
  •                IconButton(
    
  •                  icon: const Icon(Icons.delete, color: Colors.red),
    
  •                  tooltip: "Uninstall Emulator",
    
  •                  onPressed: () async {
    
  •                    final confirm = await showDialog<bool>(
    
  •                      context: context,
    
  •                      builder: (ctx) => AlertDialog(
    
  •                        title: const Text("Uninstall Emulator"),
    
  •                        content: Text("Are you sure you want to delete $emulatorName? This will remove all local files."),
    
  •                        actions: [
    
  •                          TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
    
  •                          TextButton(
    
  •                            style: TextButton.styleFrom(foregroundColor: Colors.red),
    
  •                            onPressed: () => Navigator.pop(ctx, true),
    
  •                            child: const Text("Uninstall"),
    
  •                          ),
    
  •                        ],
    
  •                      ),
    
  •                    );
    
  •                      // Listen for download completion and update state
    
  •                      ref.read(downloadProvider.notifier).stream.listen((downloads) {
    
  •                        final progress = downloads[emulatorId];
    
  •                        if (progress != null && progress.isComplete) { // Use safeContext here
    
  •                          // Update the map directly. setState will re-render using the updated map.
    
  •                          emulatorInstallStates[emulatorId] = true;
    
  •                          setState(() {}); // Trigger parent state update
    
  •                          messenger.showSnackBar(SnackBar(
    
  •                            content: Text('$emulatorName downloaded.'),
    
  •                          ));
    
  •                        }
    
  •                      });
    
  •                    },
    
  •              child: Text(isInstalled ? 'Installed' : 'Download'),
    
  •            ),
    
  •                    if (confirm == true) {
    
  •                      await directoryService.deleteEmulator(emulatorId);
    
  •                      emulatorInstallStates[emulatorId] = false;
    
  •                      setState(() {});
    
  •                      if (context.mounted) {
    
  •                        ScaffoldMessenger.of(context).showSnackBar(
    
  •                          SnackBar(content: Text("$emulatorName uninstalled.")),
    
  •                        );
    
  •                      }
    
  •                    }
    
  •                  },
    
  •                ),
    
  •              ],
    
  •            ],
    
  •            if (!isInstalled && canManage)
    
  •              ElevatedButton(
    
  •                onPressed: () => _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState),
    
  •                child: const Text('Download'),
    
  •              ),
             ],
           ),
         );
    

@@ -100,6 +155,44 @@ Widget buildEmulatorsSection( ); }

+void _startDownload(

  • BuildContext context,
  • WidgetRef ref,
  • String emulatorId,
  • String emulatorName,
  • Map<String, bool> emulatorInstallStates,
  • Function(void Function()) setState, +) {
  • final messenger = ScaffoldMessenger.of(context);
  • messenger.showSnackBar(SnackBar(
  • content: Text('Starting download for $emulatorName...'),
  • ));
  • ref.read(downloadProvider.notifier).startEmulatorDownload(emulatorId, emulatorName);
  • // Listen for download completion and update state
  • StreamSubscription? sub;
  • sub = ref.read(downloadProvider.notifier).stream.listen((downloads) {
  • final progress = downloads[emulatorId];
  • if (progress != null && (progress.isComplete || progress.error != null)) {
  •  if (progress.isComplete) {
    
  •    emulatorInstallStates[emulatorId] = true;
    
  •    if (context.mounted) setState(() {});
    
  •    messenger.showSnackBar(SnackBar(
    
  •      content: Text('$emulatorName downloaded successfully.'),
    
  •    ));
    
  •  } else if (progress.error != null) {
    
  •    if (context.mounted) setState(() {});
    
  •    messenger.showSnackBar(SnackBar(
    
  •      content: Text('Failed to download $emulatorName: ${progress.error}'),
    
  •    ));
    
  •  }
    
  •  sub?.cancel();
    
  • }
  • }); +}

// Function to build the Emulator Conflicts section Widget buildConflictsSection( StrategyRegistry registry, @@ -153,6 +246,17 @@ Widget buildConflictsSection( ), ); }),

  •  const SizedBox(height: 16),
    
  •  ElevatedButton(
    
  •    style: ElevatedButton.styleFrom(
    
  •      foregroundColor: Colors.red,
    
  •    ),
    
  •    onPressed: () async {
    
  •      await registry.clearPreferences();
    
  •      setState(() {});
    
  •    },
    
  •    child: const Text('Reset Emulator Preferences'),
    
  •  ),
    
    ], ); } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index f75081b..71a4979 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -62,13 +64,22 @@ class _SettingsScreenState extends ConsumerState { final states = <String, bool>{}; for (final def in kEmulatorDefinitions) { final id = def['id'] as String;
  •  final exe = def['windows_executable'] as String;
    
  •  final String exe;
    
  •  if (defaultTargetPlatform == TargetPlatform.macOS) {
    
  •    exe = (def['macos_executable'] as String?) ?? (def['windows_executable'] as String? ?? '');
    
  •  } else if (defaultTargetPlatform == TargetPlatform.linux) {
    
  •    exe = (def['linux_executable'] as String?) ?? '';
    
  •  } else {
    
  •    exe = (def['windows_executable'] as String?) ?? '';
    
  •  }
     if (exe.isEmpty) {
       states[id] = true; // Assume installed if no executable is defined
    
  •    debugPrint("Emulator $id installed state: ${states[id]} (Target exe: $exe)");
       continue;
     }
     // Check if emulator is installed using the directory service
     states[id] = await directoryService.isEmulatorInstalled(id, exe);
    
  •  debugPrint("Emulator $id installed state: ${states[id]} (Target exe: $exe)");
    
    } if (mounted) { setState(() { @@ -159,6 +170,8 @@ class _SettingsScreenState extends ConsumerState { setState, // Pass the setState callback ), ],
  •                const SizedBox(height: 24),
    
  •                _buildLegalSection(context),
                 ],
               ),
             );
    

@@ -410,4 +423,35 @@ class _SettingsScreenState extends ConsumerState { ], ); } +

  • // --- Legal Section ---
  • Widget _buildLegalSection(BuildContext context) {
  • return Column(
  •  crossAxisAlignment: CrossAxisAlignment.start,
    
  •  children: [
    
  •    const Text('Legal', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
    
  •    const SizedBox(height: 16),
    
  •    ListTile(
    
  •      contentPadding: EdgeInsets.zero,
    
  •      title: const Text('Third-Party Licenses'),
    
  •      subtitle: const Text('View licenses for open-source software used in Freegosy'),
    
  •      trailing: const Icon(Icons.chevron_right),
    
  •      onTap: () {
    
  •        // Register 7-Zip license before showing the page
    
  •        LicenseRegistry.addLicense(() async* {
    
  •          final license = await rootBundle.loadString('thirdparty/7zip_license.txt');
    
  •          yield LicenseEntryWithLineBreaks(['7-Zip'], license);
    
  •        });
    
  •        showLicensePage(
    
  •          context: context,
    
  •          applicationName: 'Freegosy',
    
  •          applicationVersion: '0.2.0',
    
  •          applicationLegalese: '© 2026 Freegosy Contributors.\nRedistributes 7-Zip binaries under LGPL.',
    
  •        );
    
  •      },
    
  •    ),
    
  •  ],
    
  • );
  • } } \ No newline at end of file diff --git a/lib/ui/widgets/game_card.dart b/lib/ui/widgets/game_card.dart index cda7d41..3dfe5a4 100644 --- a/lib/ui/widgets/game_card.dart +++ b/lib/ui/widgets/game_card.dart @@ -7,7 +7,9 @@ class GameCard extends StatefulWidget { final String? coverUrl; final VoidCallback onDownload; final VoidCallback onLaunch;
  • final VoidCallback? onSyncSaves;
  • final VoidCallback onDelete;
  • final VoidCallback? onPushSaves;
  • final VoidCallback? onPullSaves; final bool isDownloaded; final bool showTitle; final bool showButtonsOnHover; @@ -18,7 +20,9 @@ class GameCard extends StatefulWidget { this.coverUrl, required this.onDownload, required this.onLaunch,
  • this.onSyncSaves,
  • required this.onDelete,
  • this.onPushSaves,
  • this.onPullSaves, this.isDownloaded = false, this.showTitle = true, this.showButtonsOnHover = false, @@ -97,7 +101,7 @@ class _GameCardState extends State { valueListenable: _hovering, builder: (context, isHovering, child) { return SizedBox(
  •                  height: 90,
    
  •                  height: 80,
                     child: SingleChildScrollView(
                       physics: const NeverScrollableScrollPhysics(),
                       child: Column(
    

@@ -107,7 +111,7 @@ class _GameCardState extends State { if (!widget.showButtonsOnHover || !isHovering) if (widget.showTitle) Padding(

  •                              padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0),
    
  •                              padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
                                 child: Text(
                                   widget.game.displayName,
                                   maxLines: 2,
    

@@ -118,7 +122,7 @@ class _GameCardState extends State { ) else const Padding(

  •                              padding: EdgeInsets.symmetric(vertical: 1.0),
    
  •                              padding: EdgeInsets.symmetric(vertical: 4.0),
                                 child: Center(
                                   child: Icon(Icons.more_horiz, size: 16, color: Colors.grey),
                                 ),
    

@@ -126,38 +130,60 @@ class _GameCardState extends State {

                         if (!widget.showButtonsOnHover || isHovering)
                           Padding(
  •                            padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
    
  •                            padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0),
                               child: Row(
                                 mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                                 children: [
    
  •                                IconButton(
    
  •                                  visualDensity: VisualDensity.compact,
    
  •                                  iconSize: 22,
    
  •                                  padding: EdgeInsets.zero,
    
  •                                  constraints: const BoxConstraints(),
    
  •                                  icon: const Icon(Icons.download),
    
  •                                  onPressed: widget.onDownload,
    
  •                                  tooltip: 'Download',
    
  •                                ),
    
  •                                IconButton(
    
  •                                  visualDensity: VisualDensity.compact,
    
  •                                  iconSize: 22,
    
  •                                  padding: EdgeInsets.zero,
    
  •                                  constraints: const BoxConstraints(),
    
  •                                  icon: const Icon(Icons.play_arrow),
    
  •                                  onPressed: widget.onLaunch,
    
  •                                  tooltip: 'Launch',
    
  •                                ),
    
  •                                if (widget.onSyncSaves != null)
    
  •                                if (!widget.isDownloaded)
                                     IconButton(
                                       visualDensity: VisualDensity.compact,
    
  •                                    iconSize: 18,
    
  •                                    iconSize: 22,
                                       padding: EdgeInsets.zero,
                                       constraints: const BoxConstraints(),
    
  •                                    icon: const Icon(Icons.cloud_upload),
    
  •                                    onPressed: widget.onSyncSaves,
    
  •                                    tooltip: 'Sync saves',
    
  •                                    icon: const Icon(Icons.download),
    
  •                                    onPressed: widget.onDownload,
    
  •                                    tooltip: 'Download',
    
  •                                  )
    
  •                                else ...[
    
  •                                  IconButton(
    
  •                                    visualDensity: VisualDensity.compact,
    
  •                                    iconSize: 22,
    
  •                                    padding: EdgeInsets.zero,
    
  •                                    constraints: const BoxConstraints(),
    
  •                                    icon: const Icon(Icons.play_arrow),
    
  •                                    onPressed: widget.onLaunch,
    
  •                                    tooltip: 'Play',
    
  •                                  ),
    
  •                                  if (widget.onPushSaves != null)
    
  •                                    IconButton(
    
  •                                      visualDensity: VisualDensity.compact,
    
  •                                      iconSize: 20,
    
  •                                      padding: EdgeInsets.zero,
    
  •                                      constraints: const BoxConstraints(),
    
  •                                      icon: const Icon(Icons.cloud_upload),
    
  •                                      onPressed: widget.onPushSaves,
    
  •                                      tooltip: 'Push saves',
    
  •                                    ),
    
  •                                  if (widget.onPullSaves != null)
    
  •                                    IconButton(
    
  •                                      visualDensity: VisualDensity.compact,
    
  •                                      iconSize: 20,
    
  •                                      padding: EdgeInsets.zero,
    
  •                                      constraints: const BoxConstraints(),
    
  •                                      icon: const Icon(Icons.cloud_download),
    
  •                                      onPressed: widget.onPullSaves,
    
  •                                      tooltip: 'Pull saves',
    
  •                                    ),
    
  •                                  IconButton(
    
  •                                    visualDensity: VisualDensity.compact,
    
  •                                    iconSize: 20,
    
  •                                    padding: EdgeInsets.zero,
    
  •                                    constraints: const BoxConstraints(),
    
  •                                    icon: const Icon(Icons.delete, color: Colors.redAccent),
    
  •                                    onPressed: () => _confirmDelete(context),
    
  •                                    tooltip: 'Delete ROM',
                                     ),
    
  •                                ],
                                 ],
                               ),
                             ),
    

@@ -175,6 +201,32 @@ class _GameCardState extends State { ); }

  • void _confirmDelete(BuildContext context) {
  • showDialog(
  •  context: context,
    
  •  builder: (BuildContext dialogContext) {
    
  •    return AlertDialog(
    
  •      title: const Text('Delete ROM'),
    
  •      content: const Text('Are you sure you want to delete the local files for this game?'),
    
  •      actions: <Widget>[
    
  •        TextButton(
    
  •          child: const Text('Cancel'),
    
  •          onPressed: () => Navigator.of(dialogContext).pop(),
    
  •        ),
    
  •        TextButton(
    
  •          style: TextButton.styleFrom(foregroundColor: Colors.red),
    
  •          child: const Text('Delete'),
    
  •          onPressed: () {
    
  •            Navigator.of(dialogContext).pop();
    
  •            widget.onDelete();
    
  •          },
    
  •        ),
    
  •      ],
    
  •    );
    
  •  },
    
  • );
  • }
  • void _showContextMenu(BuildContext context) { showDialog( context: context, diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15'

+# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', {

  • 'Debug' => :debug,
  • 'Profile' => :release,
  • 'Release' => :release, +}

+def flutter_root

  • generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), FILE)
  • unless File.exist?(generated_xcode_build_settings_path)
  • raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure "flutter pub get" is executed first"
  • end
  • File.foreach(generated_xcode_build_settings_path) do |line|
  • matches = line.match(/FLUTTER_ROOT=(.*)/)
  • return matches[1].strip if matches
  • end
  • raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run "flutter pub get"" +end

+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do

  • use_frameworks!
  • flutter_install_all_macos_pods File.dirname(File.realpath(FILE))
  • target 'RunnerTests' do
  • inherit! :search_paths
  • end +end

+post_install do |installer|

  • installer.pods_project.targets.each do |target|
  • flutter_additional_macos_build_settings(target)
  • end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..76836e7 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,42 @@ +PODS:
    • flutter_secure_storage_macos (6.1.3):
    • FlutterMacOS
    • FlutterMacOS (1.0.0)
    • package_info_plus (0.0.1):
    • FlutterMacOS
    • shared_preferences_foundation (0.0.1):
    • Flutter
    • FlutterMacOS
    • sqflite_darwin (0.0.4):
    • Flutter
    • FlutterMacOS

+DEPENDENCIES:

    • flutter_secure_storage_macos (from Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos)
    • FlutterMacOS (from Flutter/ephemeral)
    • package_info_plus (from Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos)
    • shared_preferences_foundation (from Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin)
    • sqflite_darwin (from Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin)

+EXTERNAL SOURCES:

  • flutter_secure_storage_macos:
  • :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
  • FlutterMacOS:
  • :path: Flutter/ephemeral
  • package_info_plus:
  • :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
  • shared_preferences_foundation:
  • :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
  • sqflite_darwin:
  • :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin

+SPEC CHECKSUMS:

  • flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
  • FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
  • package_info_plus: 122abb51244f66eead59ce7c9c200d6b53111779
  • shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
  • sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0

+PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index d11c248..18540de 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ /* End PBXAggregateTarget section */

/* Begin PBXBuildFile section */

  •   20FED99443D842721917B5EA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 11BF9FD2E5D40BBC266BE01E /* Pods_Runner.framework */; };
      331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
      335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
      33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
      33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
      33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
      33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
    
  •   B4CD7DDD1DFB195BBB89E7B6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C132C305A1AF3E6388C6B7C2 /* Pods_RunnerTests.framework */; };
    

/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section / @@ -60,11 +62,12 @@ / End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */

  •   11BF9FD2E5D40BBC266BE01E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
      331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
      331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
      333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
      335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
    
  •   33CC10ED2044A3C60003C045 /* freegosy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "freegosy.app"; sourceTree = BUILT_PRODUCTS_DIR; };
    
  •   33CC10ED2044A3C60003C045 /* freegosy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = freegosy.app; sourceTree = BUILT_PRODUCTS_DIR; };
      33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
      33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
      33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
    

@@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements / = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D / Release.entitlements / = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D / AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; };

  •   3FB6F37F00E4A78247BF6B40 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
    
  •   645F69065E0F4B8AB271256A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
      7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
    
  •   7F03BA3B183B04F51E8CC981 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
    
  •   81CF0E3069A0EAB2590738F9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
      9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
    
  •   C132C305A1AF3E6388C6B7C2 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
    
  •   CCAA1F871C96E6DDC1498078 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
    
  •   EEECA80AC0A0FD92A265CAB6 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
    

/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (

  •   		B4CD7DDD1DFB195BBB89E7B6 /* Pods_RunnerTests.framework in Frameworks */,
      	);
      	runOnlyForDeploymentPostprocessing = 0;
      };
    

@@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (

  •   		20FED99443D842721917B5EA /* Pods_Runner.framework in Frameworks */,
      	);
      	runOnlyForDeploymentPostprocessing = 0;
      };
    

@@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests /, 33CC10EE2044A3C60003C045 / Products /, D73912EC22F37F3D000D13A0 / Frameworks */,

  •   		7236E435FDF89CA91259A96E /* Pods */,
      	);
      	sourceTree = "<group>";
      };
    

@@ -172,9 +185,24 @@ path = Runner; sourceTree = ""; };

  •   7236E435FDF89CA91259A96E /* Pods */ = {
    
  •   	isa = PBXGroup;
    
  •   	children = (
    
  •   		7F03BA3B183B04F51E8CC981 /* Pods-Runner.debug.xcconfig */,
    
  •   		3FB6F37F00E4A78247BF6B40 /* Pods-Runner.release.xcconfig */,
    
  •   		81CF0E3069A0EAB2590738F9 /* Pods-Runner.profile.xcconfig */,
    
  •   		645F69065E0F4B8AB271256A /* Pods-RunnerTests.debug.xcconfig */,
    
  •   		EEECA80AC0A0FD92A265CAB6 /* Pods-RunnerTests.release.xcconfig */,
    
  •   		CCAA1F871C96E6DDC1498078 /* Pods-RunnerTests.profile.xcconfig */,
    
  •   	);
    
  •   	path = Pods;
    
  •   	sourceTree = "<group>";
    
  •   };
      D73912EC22F37F3D000D13A0 /* Frameworks */ = {
      	isa = PBXGroup;
      	children = (
    
  •   		11BF9FD2E5D40BBC266BE01E /* Pods_Runner.framework */,
    
  •   		C132C305A1AF3E6388C6B7C2 /* Pods_RunnerTests.framework */,
      	);
      	name = Frameworks;
      	sourceTree = "<group>";
    

@@ -186,6 +214,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = (

  •   		1C4411D508300D115841BAD7 /* [CP] Check Pods Manifest.lock */,
      		331C80D1294CF70F00263BE5 /* Sources */,
      		331C80D2294CF70F00263BE5 /* Frameworks */,
      		331C80D3294CF70F00263BE5 /* Resources */,
    

@@ -204,11 +233,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = (

  •   		5C47E23C8AFB35CF3ADC0D54 /* [CP] Check Pods Manifest.lock */,
      		33CC10E92044A3C60003C045 /* Sources */,
      		33CC10EA2044A3C60003C045 /* Frameworks */,
      		33CC10EB2044A3C60003C045 /* Resources */,
      		33CC110E2044A8840003C045 /* Bundle Framework */,
      		3399D490228B24CF009A79C7 /* ShellScript */,
    
  •   		CBBB3DE13D8D81E5177BF4F9 /* [CP] Embed Pods Frameworks */,
      	);
      	buildRules = (
      	);
    

@@ -291,6 +322,28 @@ /* End PBXResourcesBuildPhase section */

/* Begin PBXShellScriptBuildPhase section */

  •   1C4411D508300D115841BAD7 /* [CP] Check Pods Manifest.lock */ = {
    
  •   	isa = PBXShellScriptBuildPhase;
    
  •   	buildActionMask = 2147483647;
    
  •   	files = (
    
  •   	);
    
  •   	inputFileListPaths = (
    
  •   	);
    
  •   	inputPaths = (
    
  •   		"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
    
  •   		"${PODS_ROOT}/Manifest.lock",
    
  •   	);
    
  •   	name = "[CP] Check Pods Manifest.lock";
    
  •   	outputFileListPaths = (
    
  •   	);
    
  •   	outputPaths = (
    
  •   		"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
    
  •   	);
    
  •   	runOnlyForDeploymentPostprocessing = 0;
    
  •   	shellPath = /bin/sh;
    
  •   	shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
    
  •   	showEnvVarsInLog = 0;
    
  •   };
      3399D490228B24CF009A79C7 /* ShellScript */ = {
      	isa = PBXShellScriptBuildPhase;
      	alwaysOutOfDate = 1;
    

@@ -329,6 +382,45 @@ shellPath = /bin/sh; shellScript = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; };

  •   5C47E23C8AFB35CF3ADC0D54 /* [CP] Check Pods Manifest.lock */ = {
    
  •   	isa = PBXShellScriptBuildPhase;
    
  •   	buildActionMask = 2147483647;
    
  •   	files = (
    
  •   	);
    
  •   	inputFileListPaths = (
    
  •   	);
    
  •   	inputPaths = (
    
  •   		"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
    
  •   		"${PODS_ROOT}/Manifest.lock",
    
  •   	);
    
  •   	name = "[CP] Check Pods Manifest.lock";
    
  •   	outputFileListPaths = (
    
  •   	);
    
  •   	outputPaths = (
    
  •   		"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
    
  •   	);
    
  •   	runOnlyForDeploymentPostprocessing = 0;
    
  •   	shellPath = /bin/sh;
    
  •   	shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
    
  •   	showEnvVarsInLog = 0;
    
  •   };
    
  •   CBBB3DE13D8D81E5177BF4F9 /* [CP] Embed Pods Frameworks */ = {
    
  •   	isa = PBXShellScriptBuildPhase;
    
  •   	buildActionMask = 2147483647;
    
  •   	files = (
    
  •   	);
    
  •   	inputFileListPaths = (
    
  •   		"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
    
  •   	);
    
  •   	name = "[CP] Embed Pods Frameworks";
    
  •   	outputFileListPaths = (
    
  •   		"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
    
  •   	);
    
  •   	runOnlyForDeploymentPostprocessing = 0;
    
  •   	shellPath = /bin/sh;
    
  •   	shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
    
  •   	showEnvVarsInLog = 0;
    
  •   };
    

/* End PBXShellScriptBuildPhase section */

/* Begin PBXSourcesBuildPhase section / @@ -380,6 +472,7 @@ / Begin XCBuildConfiguration section / 331C80DB294CF71000263BE5 / Debug */ = { isa = XCBuildConfiguration;

  •   	baseConfigurationReference = 645F69065E0F4B8AB271256A /* Pods-RunnerTests.debug.xcconfig */;
      	buildSettings = {
      		BUNDLE_LOADER = "$(TEST_HOST)";
      		CURRENT_PROJECT_VERSION = 1;
    

@@ -394,6 +487,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration;

  •   	baseConfigurationReference = EEECA80AC0A0FD92A265CAB6 /* Pods-RunnerTests.release.xcconfig */;
      	buildSettings = {
      		BUNDLE_LOADER = "$(TEST_HOST)";
      		CURRENT_PROJECT_VERSION = 1;
    

@@ -408,6 +502,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration;

  •   	baseConfigurationReference = CCAA1F871C96E6DDC1498078 /* Pods-RunnerTests.profile.xcconfig */;
      	buildSettings = {
      		BUNDLE_LOADER = "$(TEST_HOST)";
      		CURRENT_PROJECT_VERSION = 1;
    

@@ -476,8 +571,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;

  •   		"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
      		CODE_SIGN_STYLE = Automatic;
      		COMBINE_HIDPI_IMAGES = YES;
    
  •   		DEVELOPMENT_TEAM = FWFZZNYS2X;
      		INFOPLIST_FILE = Runner/Info.plist;
      		LD_RUNPATH_SEARCH_PATHS = (
      			"$(inherited)",
    

@@ -608,8 +705,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;

  •   		"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
      		CODE_SIGN_STYLE = Automatic;
      		COMBINE_HIDPI_IMAGES = YES;
    
  •   		DEVELOPMENT_TEAM = FWFZZNYS2X;
      		INFOPLIST_FILE = Runner/Info.plist;
      		LD_RUNPATH_SEARCH_PATHS = (
      			"$(inherited)",
    

@@ -628,8 +727,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;

  •   		"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
      		CODE_SIGN_STYLE = Automatic;
      		COMBINE_HIDPI_IMAGES = YES;
    
  •   		DEVELOPMENT_TEAM = FWFZZNYS2X;
      		INFOPLIST_FILE = Runner/Info.plist;
      		LD_RUNPATH_SEARCH_PATHS = (
      			"$(inherited)",
    

diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@

  • <FileRef
  •  location = "group:Pods/Pods.xcodeproj">
    
diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..775b708 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,11 +2,17 @@ - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + $(AppIdentifierPrefix)$(CFBundleIdentifier) + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..67ffb8f 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,7 +2,19 @@ - com.apple.security.app-sandbox - + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.temporary-exception.files.absolute-path.read-write + / + keychain-access-groups + + $(AppIdentifierPrefix)$(CFBundleIdentifier) + diff --git a/pubspec.yaml b/pubspec.yaml index 481f0f0..ef2ecf7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 0.2.0+1

environment: sdk: ^3.11.1 @@ -64,6 +64,8 @@ dev_dependencies: flutter: assets: - thirdparty/7zr.exe

    • thirdparty/7zz
    • thirdparty/7zip_license.txt

The following line ensures that the Material Icons font is

included with your application, so that you can use the icons in

the material Icons class.

diff --git a/test/game_card_test.dart b/test/game_card_test.dart index 16510e3..8992fc6 100644 --- a/test/game_card_test.dart +++ b/test/game_card_test.dart @@ -18,6 +18,7 @@ void main() { ), onDownload: () {}, onLaunch: () {},

  •          onDelete: () {},
           ),
         ),
       ),
    

diff --git a/thirdparty/7zip_license.txt b/thirdparty/7zip_license.txt new file mode 100644 index 0000000..15422a1 --- /dev/null +++ b/thirdparty/7zip_license.txt @@ -0,0 +1,56 @@ +7-Zip License +------------- + +7-Zip Copyright (C) 1999-2025 Igor Pavlov. + +The licenses for files are: +

    1. 7z.dll:
  • - The "GNU LGPL" as main license for most of the code
    
  • - The "GNU LGPL" with "unRAR license restriction" for some code
    
  • - The "BSD 3-clause License" for some code
    
  • - The "BSD 2-clause License" for some code
    
    1. All other files: the "GNU LGPL".

+Redistributions in binary form must reproduce related license information from this file. + +Notes: +

    • You can use 7-Zip on any computer, including a computer in a commercial
  • organization. You don't need to register or pay for 7-Zip.
    • GNU LGPL information:
  • This library is free software; you can redistribute it and/or
  • modify it under the terms of the GNU Lesser General Public
  • License as published by the Free Software Foundation; either
  • version 2.1 of the License, or (at your option) any later version.
  • This library is distributed in the hope that it will be useful,
  • but WITHOUT ANY WARRANTY; without even the implied warranty of
  • MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  • Lesser General Public License for more details.
  • You can receive a copy of the GNU Lesser General Public License from
  • http://www.gnu.org/
    • unRAR restriction:
  • The decompression engine for RAR archives was developed using source
  • code of unRAR program.
  • All copyrights to original unRAR code are owned by Alexander Roshal.
  • The license for unRAR source code specifies that "no part of
  • unRAR source code may be used to recreate the RAR compression algorithm,
  • which is proprietary".
    • BSD 3-clause License information:
  • The code for LZFSE decompression was developed by Apple Inc.
  • The code for ZSTD decompression was developed by Facebook (Meta).
  • Redistribution and use in binary form must reproduce the copyright notice,
  • this list of conditions and the following disclaimer in the documentation
  • and/or other materials provided with the distribution.
    • BSD 2-clause License information:
  • The code for XXH64 was developed by Yann Collet.
  • Redistribution and use in binary form must reproduce the copyright notice
  • and the following disclaimer in the documentation and/or other materials
  • provided with the distribution. diff --git a/thirdparty/7zz b/thirdparty/7zz new file mode 100755 index 0000000..28d5488 Binary files /dev/null and b/thirdparty/7zz differ

Clone this wiki locally