-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit e39958e
Commit: e39958e6bb7a6e0147a18b208d2e696e340d2968
Author: abduznik
Date: 2026-03-30
- 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
- Created AzaharSaveStrategy with manual folder mapping and zip-based sync.
- Integrated Azahar strategy into SaveSyncService and updated slug mapping logic.
- Updated EmulatorDownloadService to support platform-specific GitHub asset filters (required/excluded).
- Expanded emulator_registry_data with macOS-specific 3DS configurations and executable paths.
- Enabled 3DS core support in RetroArchStrategy with dynamic core pathing and system folder automation.
- Added automatic 3DS shared font downloader and system directory management in DirectoryService.
- Improved StrategyRegistry grouping logic to sync preferences across related platform slugs (e.g., 3ds and new-nintendo-3ds).
- 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.
.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(-)
.gitignoreREADME.mdagent.mdios/Flutter/Debug.xcconfigios/Flutter/Release.xcconfigios/Podfilelib/core/downloader/download_service.dartlib/core/emulator/emulator_download_service.dartlib/core/emulator/emulator_registry_data.dartlib/core/emulator/emulator_strategy.dartlib/core/emulator/strategies/azahar_strategy.dartlib/core/emulator/strategies/cemu_strategy.dartlib/core/emulator/strategies/dolphin_strategy.dartlib/core/emulator/strategies/duckstation_strategy.dartlib/core/emulator/strategies/eden_strategy.dartlib/core/emulator/strategies/flycast_strategy.dartlib/core/emulator/strategies/mame_strategy.dartlib/core/emulator/strategies/melonds_strategy.dartlib/core/emulator/strategies/mgba_strategy.dartlib/core/emulator/strategies/pcsx2_strategy.dartlib/core/emulator/strategies/ppsspp_strategy.dartlib/core/emulator/strategies/retroarch_strategy.dartlib/core/emulator/strategies/rpcs3_strategy.dartlib/core/emulator/strategies/windows_strategy.dartlib/core/emulator/strategies/xemu_strategy.dartlib/core/emulator/strategies/xenia_strategy.dartlib/core/emulator/strategy_registry.dartlib/core/extraction/extraction_service.dartlib/core/romm/romm_models.dartlib/core/romm/romm_service.dartlib/core/save/save_strategy.dartlib/core/save/save_sync_service.dartlib/core/save/strategies/azahar_save_strategy.dartlib/core/save/strategies/cemu_save_strategy.dartlib/core/save/strategies/dolphin_save_strategy.dartlib/core/save/strategies/duckstation_save_strategy.dartlib/core/save/strategies/eden_save_strategy.dartlib/core/save/strategies/mgba_save_strategy.dartlib/core/save/strategies/pcsx2_save_strategy.dartlib/core/save/strategies/ppsspp_save_strategy.dartlib/core/save/strategies/rpcs3_save_strategy.dartlib/core/storage/directory_service.dartlib/providers/library_provider.dartlib/ui/screens/library_screen.dartlib/ui/screens/settings_emulators_section.dartlib/ui/screens/settings_screen.dartlib/ui/widgets/game_card.dartmacos/Flutter/Flutter-Debug.xcconfigmacos/Flutter/Flutter-Release.xcconfigmacos/Podfilemacos/Podfile.lockmacos/Runner.xcodeproj/project.pbxprojmacos/Runner.xcworkspace/contents.xcworkspacedatamacos/Runner/DebugProfile.entitlementsmacos/Runner/Release.entitlementspubspec.yamltest/game_card_test.dartthirdparty/7zip_license.txtthirdparty/7zz
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; -} -```
-
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',
- 'windows_url': 'https://buildbot.libretro.com/stable/1.19.1/windows/x86_64/RetroArch.7z',
- 'windows_url': 'https://buildbot.libretro.com/stable/2.0/windows/x86_64/RetroArch_x64.zip', 'windows_executable': 'RetroArch.exe',
- 'linux_url': 'https://buildbot.libretro.com/stable/2.0/linux/x86_64/RetroArch.zip', 'linux_executable': 'retroarch',
- 'macos_url': 'https://buildbot.libretro.com/stable/1.22.2/apple/osx/universal/RetroArch_Metal.dmg',
- 'macos_executable': 'RetroArch.app/Contents/MacOS/RetroArch', 'platform_slugs': [ 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 'psx', 'ps1', 'playstation', 'psp', 'dc', 'dreamcast', 'segacd', 'saturn', 'megadrive', 'genesis', 'md', 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame',
-
'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, -
); 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(
getExecutableForPlatform(),
-
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, -
); } @@ -148,60 +264,103 @@ class RetroArchStrategy extends EmulatorStrategy { throw Exception('$name not found. Please download it first.'); }
['-L', corePath, normalizedRomPath], mode: ProcessStartMode.detached,
- 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')) { -
} finally {
final outFile = io.File(p.join(coresDir, entry.name)); await outFile.parent.create(recursive: true); await outFile.writeAsBytes(entry.content as List<int>); } }
-
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) {
-
} else {
await _handleZip(archivePath, destDir);
-
// 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) {
-
} } } 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, ); }
debugPrint('Warning: Could not sanitize app bundle at $appPath: $e'); - 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?) ?? -
); } } 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';
(json['games_count'] as int?) ?? 0,
@@ -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, ); -
} catch (e) {
if (response.statusCode == 200 && response.data != null) { return Uint8List.fromList(response.data!); } 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';
debugPrint("ERROR in downloadSave: $e"); return null;
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); -
} catch (e) {
} return uploaded > 0; -
} }
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';
-
} catch (e) { // ignore @@ -52,11 +60,11 @@ class DuckstationSaveStrategy extends SaveStrategy { final result = []; final stem = getRomStem(game);
return p.join(localAppData, 'DuckStation'); }
- 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>.cnmtin 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>.cnmtstrings. - ///
- /// 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); } -
} catch (e) {
debugPrint('[Eden][Scanner] No .cnmt match in first 256 KB');
-
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);
-
} catch (e) {
await io.Directory(outPath).create(recursive: true); } } return true; -
} } 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';
debugPrint('[Eden][Extract] ERROR: $e'); return false;
@@ -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); -
} 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';
return true;
/// 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);
-
} 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';
await io.File(targetPath).writeAsBytes(data); return true;
- 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();
-
}).toList();
final folderLower = d.path.split(p.separator).last.toLowerCase(); return gameWords.any((word) => folderLower.contains(word));
@@ -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); -
} if (mounted) { setState(() { @@ -159,6 +170,8 @@ class _SettingsScreenState extends ConsumerState { setState, // Pass the setState callback ), ],
debugPrint("Emulator $id installed state: ${states[id]} (Target exe: $exe)"); -
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)
- flutter_secure_storage_macos (from
-
- FlutterMacOS (from
Flutter/ephemeral)
- FlutterMacOS (from
-
- package_info_plus (from
Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos)
- package_info_plus (from
-
- shared_preferences_foundation (from
Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin)
- shared_preferences_foundation (from
-
- sqflite_darwin (from
Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin)
- sqflite_darwin (from
+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">
environment: sdk: ^3.11.1 @@ -64,6 +64,8 @@ dev_dependencies: flutter: assets: - thirdparty/7zr.exe
-
- thirdparty/7zz
-
- thirdparty/7zip_license.txt
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: +
-
- 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 -
- 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