-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit f5ce6a1
abduznik edited this page May 23, 2026
·
1 revision
Commit: f5ce6a18533e47a9c1f03ee59041f500320ca6e6
Author: abduznik
Date: 2026年03月24日
-
feat: add RPCS3, PCSX2, Azahar, Cemu, Duckstation, Xemu, Xenia emulators with GitHub release downloads
-
v0.0.2 — Windows game support, 7z extraction, expanded save sync (PCSX2, RPCS3, Xenia)
-
fix: resolve all analyzer warnings and infos (unused imports, unused field, build context sync)
Why: Adds a new feature or capability to the application.
README.md | 81 +++++++--
agent.md | 54 ++++--
lib/core/downloader/download_service.dart | 27 ++-
lib/core/emulator/emulator_download_service.dart | 80 +++++++-
lib/core/emulator/emulator_registry_data.dart | 96 +++++++++-
lib/core/emulator/github_release_service.dart | 50 +++++
lib/core/emulator/strategies/azahar_strategy.dart | 43 +++++
lib/core/emulator/strategies/cemu_strategy.dart | 40 ++++
.../emulator/strategies/duckstation_strategy.dart | 40 ++++
lib/core/emulator/strategies/pcsx2_strategy.dart | 40 ++++
lib/core/emulator/strategies/rpcs3_strategy.dart | 40 ++++
lib/core/emulator/strategies/windows_strategy.dart | 109 +++++++++++
lib/core/emulator/strategies/xemu_strategy.dart | 40 ++++
lib/core/emulator/strategies/xenia_strategy.dart | 40 ++++
lib/core/emulator/strategy_registry.dart | 25 ++-
lib/core/save/save_sync_service.dart | 45 ++++-
lib/core/save/strategies/pcsx2_save_strategy.dart | 89 +++++++++
lib/core/save/strategies/rpcs3_save_strategy.dart | 122 +++++++++++++
.../save/strategies/windows_save_strategy.dart | 135 ++++++++++++++
lib/core/save/strategies/xenia_save_strategy.dart | 109 +++++++++++
lib/core/storage/directory_service.dart | 42 ++++-
lib/core/windows/pcgamingwiki_service.dart | 202 +++++++++++++++++++++
lib/core/windows/windows_game_service.dart | 73 ++++++++
lib/providers/romm_provider.dart | 15 +-
lib/ui/screens/library_screen.dart | 74 ++++++--
lib/ui/widgets/windows_game_config_dialog.dart | 137 ++++++++++++++
pubspec.yaml | 3 +-
thirdparty/7zr.exe | Bin 0 -> 601088 bytes
28 files changed, 1769 insertions(+), 82 deletions(-)
README.mdagent.mdlib/core/downloader/download_service.dartlib/core/emulator/emulator_download_service.dartlib/core/emulator/emulator_registry_data.dartlib/core/emulator/github_release_service.dartlib/core/emulator/strategies/azahar_strategy.dartlib/core/emulator/strategies/cemu_strategy.dartlib/core/emulator/strategies/duckstation_strategy.dartlib/core/emulator/strategies/pcsx2_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/save/save_sync_service.dartlib/core/save/strategies/pcsx2_save_strategy.dartlib/core/save/strategies/rpcs3_save_strategy.dartlib/core/save/strategies/windows_save_strategy.dartlib/core/save/strategies/xenia_save_strategy.dartlib/core/storage/directory_service.dartlib/core/windows/pcgamingwiki_service.dartlib/core/windows/windows_game_service.dartlib/providers/romm_provider.dartlib/ui/screens/library_screen.dartlib/ui/widgets/windows_game_config_dialog.dartpubspec.yamlthirdparty/7zr.exe
diff --git a/README.md b/README.md index 934e1b6..c8a824d 100644 --- a/README.md +++ b/README.md @@ -2,46 +2,87 @@ 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.0.2 + +### Full Windows Native Game Support +- Download Windows games (.zip and .7z) with automatic extraction +- Auto-detection of game executables in extracted folders +- Manual exe and save path configuration via long-press on any Windows game card +- Crash detection on launch — if a game exits immediately, a clear error message explains likely causes (missing DirectX, Visual C++ redistributables, etc.) +- Save sync for Windows games via PCGamingWiki automatic save path detection, with manual override support +- All exe and save path overrides persist across app restarts + +### Expanded Save Sync +- **PCSX2 (PS2)**: Syncs memory cards (Mcd001.ps2, Mcd002.ps2) and save states +- **RPCS3 (PS3)**: Syncs save data folders by title ID +- **Xenia Canary (Xbox 360)**: Syncs content save folders by title ID +- **Windows games**: Full save directory packaging into zip for upload, auto-extraction on restore +- All multi-file save strategies package saves as a single zip for clean cloud storage + +### 7-Zip Support +- Bundled 7zr.exe for .7z extraction — no manual installation required +- Automatically extracted to AppData on first run +- Full .7z support for Windows game downloads + +### Quality of Life +- Launch error messages now display for 8 seconds for better readability +- Stale exe overrides (pointing to moved/deleted files) are automatically discarded and re-detected +- ROM detection improved for Windows games — returns game folder instead of individual file + ## Currently Working -- **RomM Integration**: Browse and filter your entire RomM game library -- **ROM Downloads**: Download games via HTTP from your RomM server (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 - **Game Launching**: Launch games directly from the app using: - - RetroArch (multi-system emulator) + - RetroArch (GBA, GBC, GB, NES, SNES, N64, NDS, PSX, PSP, Dreamcast, Megadrive, and more) - Dolphin (GameCube & Wii) - - Eden (SNES/Genesis) + - Eden (Nintendo Switch) + - RPCS3 (PlayStation 3) + - PCSX2 (PlayStation 2) + - DuckStation (PlayStation 1) + - Azahar (Nintendo 3DS) + - Cemu (Wii U) + - Xemu (Xbox) + - Xenia Canary (Xbox 360) + - Windows Native (PC games) +- **Emulator Downloads**: One-tap emulator download and installation from Settings +- **Save Sync**: Two-way save synchronization with RomM cloud for: + - RetroArch (all supported platforms) + - Dolphin (GameCube/Wii) + - Eden (Switch) + - PCSX2 (PS2) + - RPCS3 (PS3) + - Xenia Canary (Xbox 360) + - Windows native games (via PCGamingWiki auto-detection) ## Roadmap -### Near Term: Expanded Emulator Support +### Near Term -Expanding launcher support to cover more systems with download capabilities: -- Dreamcast -- RPCS3 (PlayStation 3) -- RPCS2 (PlayStation 2) -- Duckstation (PlayStation 1) -- MelonDS (Nintendo DS) -- Azure (Nintendo 3DS) +- **Cemu save sync** — Wii U saves at mlc01/usr/save/ +- **Azahar save sync** — 3DS saves via SDMC folder +- **MelonDS support** — better NDS emulation alternative +- **macOS & Linux support** — platform detection and path resolution already structured for future expansion ### End-Game Features -- Download emulators automatically with one tap -- Download ROMs from RomM library (currently working) -- Add custom ROMs for new platforms -- Double-sided save synchronization: sync saves from RomM Cloud to device before playing, and sync back after you're done +- Automatic emulator updates +- Custom ROM platform tagging +- Mobile companion app for on-the-go library browsing ### Cross-Platform Vision -Freegosy is designed as a truly cross-platform experience, allowing you to manage your game library seamlessly across desktop, mobile, and tablet devices. +Freegosy is designed as a truly cross-platform experience. The codebase is structured to support Windows, macOS, and Linux with platform-specific code isolated behind strategy patterns and service abstractions. ## Status -Stay tuned for releases! Freegosy is actively under development with regular updates coming soon. +Actively under development. Release 0.0.2 focuses on Windows game support and expanded save sync coverage. ## About RomM -Freegosy is built to complement [RomM](https://github.com/rommapp/romm), a modern ROM manager. It connects to your RomM instance to provide a lightweight, portable way to access and play your games on the go. +Freegosy is built to complement [RomM](https://github.com/rommapp/romm), a modern ROM manager. It connects to your RomM instance to provide a lightweight, portable way to access and play your games. ## Contributing -We welcome contributions! Please check out our project structure in `agent.md` for guidelines on how to add new emulators or features. +Check out `agent.md` for the full file map, coding rules, and contracts for adding new emulators, save strategies, or features. \ No newline at end of file diff --git a/agent.md b/agent.md index 9e48305..de0be33 100644 --- a/agent.md +++ b/agent.md @@ -10,9 +10,11 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin - All RomM API calls go through `romm_service.dart` only. Never call the API directly from UI or providers. - All emulator logic goes through the strategy pattern. Never hardcode emulator behavior in UI. - New emulator = new file in `core/emulator/strategies/`, register in `strategy_registry.dart` only. +- New save strategy = new file in `core/save/strategies/`, register in `save_sync_service.dart` only. - 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. ## File Map @@ -26,43 +28,61 @@ Freegosy is a cross-platform Flutter app for browsing a RomM library, downloadin ### Core — Save Sync - `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() (3-version rotation), getRomStem(). -- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). Wires strategies to RommService. +- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). 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. - `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci. -- `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. +- `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/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%\rpcs3\dev_hdd0\home00000001円\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/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. ### Core — Emulator - `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform(). - `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions. - `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Methods: getStrategyForSlug(), getDefinition(). -- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy implementation. -- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy implementation. -- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy implementation. -- `lib/core/emulator/emulator_download_service.dart` — Service for downloading and extracting emulators. +- `lib/core/emulator/emulator_download_service.dart` — Service for downloading and extracting emulators. Supports direct URL and GitHub release types. Handles .zip and .7z extraction. +- `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. +- `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/xemu_strategy.dart` — Xemu strategy. Slugs: xbox. +- `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_`). ### Core — Downloader -- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Exposes a Stream<DownloadProgress> for UI progress tracking. Updated to use DirectoryService. +- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Handles .zip extraction via archive package and .7z via bundled 7zr.exe (resolved via DirectoryService.resolveSevenZipPath()). Windows games (.zip/.7z) always extracted regardless of isMultiFile flag. ### Core — Storage -- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories, including persistence via SharedPreferences. +- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets to %APPDATA%\Freegosy\thirdparty\ on first run. Uses defaultTargetPlatform for Windows check, Process.run for APPDATA resolution. + +### Core — Windows +- `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder (hint match then largest). Skips uninstall/setup/redist/etc. Launches via Process.start detached. +- `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations. Parses MediaWiki markup, expands environment variables (APPDATA, LOCALAPPDATA etc), returns resolved paths. ### Core — Updater - `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads new binary to temp, swaps, relaunches. ### Providers -- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, EmulatorDownloadService, and SaveSyncService. -- `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search and filtering logic. +- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry (loads WindowsStrategy persisted overrides on init), SaveSyncService (loads WindowsSaveStrategy persisted overrides on init). +- `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search, filtering, card aspect ratio, and RetroArch sync mode persistence. - `lib/providers/download_provider.dart` — Riverpod providers for active downloads and progress. ### UI — Screens -- `lib/ui/screens/library_screen.dart` — Main screen. Shows platform filter bar, game grid, search bar, and game count display. +- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, download, launch, save sync. Windows games support long-press to open config dialog and auto-show config on missing exe. Launch errors show for 8 seconds. - `lib/ui/screens/download_screen.dart` — Active downloads list with progress bars. -- `lib/ui/screens/settings_screen.dart` — Storage and Emulator management section. Includes path selection, emulator installation status, and download buttons. +- `lib/ui/screens/settings_screen.dart` — RomM server config, card aspect ratio, storage paths, RetroArch sync mode, emulator download/install status. ### UI — Widgets -- `lib/ui/widgets/game_card.dart` — Single game tile. Shows cover, name, platform. Tappable to launch or download. +- `lib/ui/widgets/game_card.dart` — Single game tile. Shows cover, name, download/launch/sync buttons. Green dot when downloaded. - `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar and cancel button. - `lib/ui/widgets/platform_filter_bar.dart` — Horizontal scrollable platform chip row with distinct styling for selected/unselected states. +- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game exe path and save directory. Browse buttons for both. Returns `Map<String, String>` with keys `exe` and `save`. Only shown for Windows platform games. ## Key Contracts @@ -107,7 +127,9 @@ class RomMConfig { - `flutter_riverpod` — state management - `dio` — HTTP client for API calls and downloads - `path_provider` — platform-safe file paths -- `shared_preferences` — persist RomM config locally +- `shared_preferences` — persist RomM config, card ratio, sync mode, Windows exe/save overrides - `package_info_plus` — read current app version for updater -- `archive` — for zip extraction -- `file_picker` — for directory selection +- `archive` — zip extraction and creation (ZipDecoder, ZipFileEncoder) +- `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 diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart index 927d303..b3e5a42 100644 --- a/lib/core/downloader/download_service.dart +++ b/lib/core/downloader/download_service.dart @@ -65,7 +65,10 @@ class DownloadService { deleteOnError: true, ).then((_) async { try { - if (game.isMultiFile) { + final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); + final isArchive = savePath.toLowerCase().endsWith('.zip') || + savePath.toLowerCase().endsWith('.7z'); + if (game.isMultiFile || (isWindowsGame && isArchive)) { controller.add(DownloadProgress( id: game.id, gameName: game.name, @@ -112,11 +115,25 @@ class DownloadService { await Directory(extractDir).create(recursive: true); - final bytes = await File(zipPath).readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); - extractArchiveToDisk(archive, extractDir); + if (zipPath.toLowerCase().endsWith('.7z')) { + 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', zipPath, '-o$extractDir', '-y'], + runInShell: false, + ); + if (result.exitCode != 0) { + throw Exception('7z extraction failed: ${result.stderr}'); + } + } else { + final bytes = await File(zipPath).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + extractArchiveToDisk(archive, extractDir); + } - // Delete the zip after extraction + // Delete the archive after extraction await File(zipPath).delete(); // Log the largest file found (main ROM) diff --git a/lib/core/emulator/emulator_download_service.dart b/lib/core/emulator/emulator_download_service.dart index e40b1a2..979da86 100644 --- a/lib/core/emulator/emulator_download_service.dart +++ b/lib/core/emulator/emulator_download_service.dart @@ -7,12 +7,16 @@ import 'package:archive/archive_io.dart'; import '../downloader/download_service.dart'; import '../storage/directory_service.dart'; import 'emulator_registry_data.dart'; +import 'github_release_service.dart'; class EmulatorDownloadService { final Dio _dio; final DirectoryService _directoryService; + late final GithubReleaseService _githubService; - EmulatorDownloadService(this._dio, this._directoryService); + EmulatorDownloadService(this._dio, this._directoryService) { + _githubService = GithubReleaseService(_dio); + } Stream<DownloadProgress> downloadEmulator(String emulatorId) async* { final definition = kEmulatorDefinitions.firstWhere( @@ -21,17 +25,53 @@ class EmulatorDownloadService { ); if (definition.isEmpty) { - yield DownloadProgress(id: emulatorId, gameName: emulatorId, error: 'Emulator definition not found'); + yield DownloadProgress( + id: emulatorId, + gameName: emulatorId, + error: 'Emulator definition not found', + ); return; } final String emulatorName = definition['name'] as String? ?? emulatorId; - final String? downloadUrl = Platform.isWindows - ? definition['windows_url'] as String? - : definition['linux_url'] as String?; + final String type = definition['type'] as String? ?? 'direct'; + + // Resolve download URL based on type + String? downloadUrl; + if (type == 'github') { + yield DownloadProgress( + id: emulatorId, + 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'] ?? []); + downloadUrl = await _githubService.getLatestReleaseUrl( + repo: repo, + required: required, + excluded: excluded, + ); + if (downloadUrl == null) { + yield DownloadProgress( + id: emulatorId, + gameName: emulatorName, + error: 'No matching release asset found on GitHub', + ); + return; + } + } else { + downloadUrl = Platform.isWindows + ? definition['windows_url'] as String? + : definition['linux_url'] as String?; + } if (downloadUrl == null) { - yield DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'No download URL for this platform'); + yield DownloadProgress( + id: emulatorId, + gameName: emulatorName, + error: 'No download URL for this platform', + ); return; } @@ -54,34 +94,54 @@ class EmulatorDownloadService { percent: received / total, bytesReceived: received, totalBytes: total, + status: 'Downloading...', )); } }, deleteOnError: true, ).then((_) async { try { + controller.add(DownloadProgress( + id: emulatorId, + gameName: emulatorName, + percent: 1.0, + status: 'Extracting...', + )); await _extractArchive(tempFilePath, emulatorDir); controller.add(DownloadProgress( id: emulatorId, gameName: emulatorName, percent: 1.0, isComplete: true, + status: 'Done!', )); } catch (e) { - controller.add(DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'Extraction failed: $e')); + controller.add(DownloadProgress( + id: emulatorId, + gameName: emulatorName, + error: 'Extraction failed: $e', + )); } finally { controller.close(); final f = File(tempFilePath); if (await f.exists()) await f.delete(); } }).catchError((e) { - controller.add(DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'Download failed: $e')); + controller.add(DownloadProgress( + id: emulatorId, + gameName: emulatorName, + error: 'Download failed: $e', + )); controller.close(); }); yield* controller.stream; } catch (e) { - yield DownloadProgress(id: emulatorId, gameName: emulatorName, error: 'Error: $e'); + yield DownloadProgress( + id: emulatorId, + gameName: emulatorName, + error: 'Error: $e', + ); } } @@ -102,4 +162,4 @@ class EmulatorDownloadService { throw Exception('Unsupported archive format: $archivePath'); } } -} +} \ No newline at end of file diff --git a/lib/core/emulator/emulator_registry_data.dart b/lib/core/emulator/emulator_registry_data.dart index e02b4c2..0aefadf 100644 --- a/lib/core/emulator/emulator_registry_data.dart +++ b/lib/core/emulator/emulator_registry_data.dart @@ -2,19 +2,21 @@ 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_executable': 'RetroArch.exe', 'linux_executable': 'retroarch', 'platform_slugs': [ - 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds', 'psx', 'psp', - 'segacd', 'saturn', 'dreamcast', 'megadrive', 'genesis', 'gamegear', - 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame', + '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' ], }, { 'id': 'dolphin', 'name': 'Dolphin', + 'type': 'direct', 'windows_url': 'https://dl.dolphin-emu.org/builds/dolphin-master-latest-x64.7z', 'windows_executable': 'Dolphin.exe', 'linux_executable': 'dolphin-emu', @@ -23,9 +25,95 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [ { 'id': 'eden', 'name': 'Eden (Switch)', + 'type': 'direct', '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', 'platform_slugs': ['switch', 'nintendo-switch', 'ns'], }, -]; + { + 'id': 'rpcs3', + 'name': 'RPCS3 (PlayStation 3)', + 'type': 'github', + 'github_repo': 'RPCS3/rpcs3', + 'github_asset_required': ['win64'], + 'github_asset_excluded': ['debug'], + 'windows_executable': 'rpcs3.exe', + 'linux_executable': 'rpcs3', + 'platform_slugs': ['ps3', 'playstation-3', 'playstation3'], + }, + { + 'id': 'pcsx2', + 'name': 'PCSX2 (PlayStation 2)', + 'type': 'github', + 'github_repo': 'PCSX2/pcsx2', + 'github_asset_required': ['windows', 'Qt'], + 'github_asset_excluded': ['debug', 'sse4', 'arm64'], + 'windows_executable': 'pcsx2-qt.exe', + 'linux_executable': 'pcsx2-qt', + 'platform_slugs': ['ps2', 'playstation-2', 'playstation2'], + }, + { + 'id': 'azahar', + 'name': 'Azahar (Nintendo 3DS)', + 'type': 'github', + 'github_repo': 'azahar-emu/azahar', + 'github_asset_required': ['windows', 'msys2'], + 'github_asset_excluded': ['debug', 'appimage'], + 'windows_executable': 'azahar.exe', + 'linux_executable': 'azahar', + 'platform_slugs': ['3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'], + }, + { + 'id': 'cemu', + 'name': 'Cemu (Wii U)', + 'type': 'github', + 'github_repo': 'cemu-project/Cemu', + 'github_asset_required': ['windows'], + 'github_asset_excluded': ['experimental', 'debug'], + 'windows_executable': 'Cemu.exe', + 'linux_executable': 'cemu', + 'platform_slugs': ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu'], + }, + { + 'id': 'xemu', + 'name': 'Xemu (Xbox)', + 'type': 'github', + 'github_repo': 'xemu-project/xemu', + 'github_asset_required': ['win', 'x86_64', 'release'], + 'github_asset_excluded': ['dbg', 'pdb', 'arm', 'macos', 'appimage', 'tar'], + 'windows_executable': 'xemu.exe', + 'linux_executable': 'xemu', + 'platform_slugs': ['xbox'], + }, + { + 'id': 'xenia_canary', + 'name': 'Xenia Canary (Xbox 360)', + 'type': 'github', + 'github_repo': 'xenia-canary/xenia-canary', + 'github_asset_required': ['windows'], + 'github_asset_excluded': ['debugoptimized', 'debug', 'pdb'], + 'windows_executable': 'xenia_canary.exe', + 'linux_executable': 'xenia_canary', + 'platform_slugs': ['xbox360', 'xbla'], + }, + { + 'id': 'duckstation', + 'name': 'DuckStation (PlayStation 1)', + 'type': 'github', + 'github_repo': 'stenzek/duckstation', + 'github_asset_required': ['windows', 'x64'], + 'github_asset_excluded': ['nogui', 'debug', 'arm64'], + 'windows_executable': 'duckstation-qt-x64-ReleaseLTCG.exe', + 'linux_executable': 'duckstation-qt', + 'platform_slugs': ['ps1', 'playstation', 'psx'], + }, + { + 'id': 'windows_native', + 'name': 'Windows (Native)', + 'type': 'none', + 'windows_executable': '', + 'linux_executable': '', + 'platform_slugs': ['windows', 'pc', 'win'], + }, +]; \ No newline at end of file diff --git a/lib/core/emulator/github_release_service.dart b/lib/core/emulator/github_release_service.dart new file mode 100644 index 0000000..404a530 --- /dev/null +++ b/lib/core/emulator/github_release_service.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +class GithubReleaseService { + final Dio _dio; + + GithubReleaseService(this._dio); + + /// Fetches the latest release download URL from a GitHub repo + /// that matches all [required] filters and none of the [excluded] filters. + Future<String?> getLatestReleaseUrl({ + required String repo, + required List<String> required, + List<String> excluded = const [], + }) async { + try { + final response = await _dio.get( + 'https://api.github.com/repos/$repo/releases/latest', + options: Options(headers: {'Accept': 'application/vnd.github.v3+json'}), + ); + + if (response.statusCode != 200) return null; + + final assets = response.data['assets'] as List<dynamic>; + debugPrint('[GithubReleaseService] $repo — ${assets.length} assets found'); + + for (final asset in assets) { + final name = (asset['name'] as String).toLowerCase(); + final url = asset['browser_download_url'] as String; + + // Must match all required filters + final matchesRequired = required.every((f) => name.contains(f.toLowerCase())); + if (!matchesRequired) continue; + + // Must not match any excluded filters + final matchesExcluded = excluded.any((f) => name.contains(f.toLowerCase())); + if (matchesExcluded) continue; + + debugPrint('[GithubReleaseService] matched asset: $name'); + return url; + } + + debugPrint('[GithubReleaseService] no matching asset found for $repo'); + return null; + } catch (e) { + debugPrint('[GithubReleaseService] error fetching $repo: $e'); + return null; + } + } +} \ No newline at end of file diff --git a/lib/core/emulator/strategies/azahar_strategy.dart b/lib/core/emulator/strategies/azahar_strategy.dart new file mode 100644 index 0000000..9e7b673 --- /dev/null +++ b/lib/core/emulator/strategies/azahar_strategy.dart @@ -0,0 +1,43 @@ +import 'dart: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'; + +class AzaharStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + AzaharStrategy(this._directoryService); + + @override + String get name => 'Azahar'; + + @override + String get emulatorId => 'azahar'; + + @override + List<String> get supportedSlugs => [ + '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', + 'new-nintendo-3ds', 'new-nintendo-3ds-xl', + ]; + + @override + String get windowsExecutable => 'azahar.exe'; + + @override + String get linuxExecutable => 'azahar'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @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 new file mode 100644 index 0000000..60732ea --- /dev/null +++ b/lib/core/emulator/strategies/cemu_strategy.dart @@ -0,0 +1,40 @@ +import 'dart: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'; + +class CemuStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + CemuStrategy(this._directoryService); + + @override + String get name => 'Cemu'; + + @override + String get emulatorId => 'cemu'; + + @override + List<String> get supportedSlugs => ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu']; + + @override + String get windowsExecutable => 'Cemu.exe'; + + @override + String get linuxExecutable => 'cemu'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, ['-g', romPath], mode: ProcessStartMode.detached); + } + + @override + String resolveSavePath(Game game) => ''; +} \ No newline at end of file diff --git a/lib/core/emulator/strategies/duckstation_strategy.dart b/lib/core/emulator/strategies/duckstation_strategy.dart new file mode 100644 index 0000000..74f2663 --- /dev/null +++ b/lib/core/emulator/strategies/duckstation_strategy.dart @@ -0,0 +1,40 @@ +import 'dart: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'; + +class DuckstationStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + DuckstationStrategy(this._directoryService); + + @override + String get name => 'DuckStation'; + + @override + String get emulatorId => 'duckstation'; + + @override + List<String> get supportedSlugs => ['ps1', 'playstation', 'psx']; + + @override + String get windowsExecutable => 'duckstation-qt-x64-ReleaseLTCG.exe'; + + @override + String get linuxExecutable => 'duckstation-qt'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, ['-batch', romPath], mode: ProcessStartMode.detached); + } + + @override + String resolveSavePath(Game game) => ''; +} \ No newline at end of file diff --git a/lib/core/emulator/strategies/pcsx2_strategy.dart b/lib/core/emulator/strategies/pcsx2_strategy.dart new file mode 100644 index 0000000..75ec8b1 --- /dev/null +++ b/lib/core/emulator/strategies/pcsx2_strategy.dart @@ -0,0 +1,40 @@ +import 'dart: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'; + +class Pcsx2Strategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + Pcsx2Strategy(this._directoryService); + + @override + String get name => 'PCSX2'; + + @override + String get emulatorId => 'pcsx2'; + + @override + List<String> get supportedSlugs => ['ps2', 'playstation-2', 'playstation2']; + + @override + String get windowsExecutable => 'pcsx2-qt.exe'; + + @override + String get linuxExecutable => 'pcsx2-qt'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @override + String resolveSavePath(Game game) => ''; +} \ No newline at end of file diff --git a/lib/core/emulator/strategies/rpcs3_strategy.dart b/lib/core/emulator/strategies/rpcs3_strategy.dart new file mode 100644 index 0000000..9828c67 --- /dev/null +++ b/lib/core/emulator/strategies/rpcs3_strategy.dart @@ -0,0 +1,40 @@ +import 'dart: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'; + +class Rpcs3Strategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + Rpcs3Strategy(this._directoryService); + + @override + String get name => 'RPCS3'; + + @override + String get emulatorId => 'rpcs3'; + + @override + List<String> get supportedSlugs => ['ps3', 'playstation-3', 'playstation3']; + + @override + String get windowsExecutable => 'rpcs3.exe'; + + @override + String get linuxExecutable => 'rpcs3'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @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 new file mode 100644 index 0000000..17cf244 --- /dev/null +++ b/lib/core/emulator/strategies/windows_strategy.dart @@ -0,0 +1,109 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:freegosy/core/emulator/emulator_strategy.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; +import 'package:freegosy/core/storage/directory_service.dart'; +import 'package:freegosy/core/windows/windows_game_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class WindowsStrategy extends EmulatorStrategy { + final WindowsGameService _windowsGameService; + + // Manual exe overrides per game id + final Map<String, String> _exeOverrides = {}; + + WindowsStrategy(DirectoryService directoryService) + : _windowsGameService = WindowsGameService(); + + @override + String get name => 'Windows'; + + @override + String get emulatorId => 'windows_native'; + + @override + List<String> get supportedSlugs => ['windows', 'pc', 'win']; + + @override + String get windowsExecutable => ''; + + @override + String get linuxExecutable => ''; + + @override + bool get supportsSaveSync => true; + + static const String _prefsPrefix = 'win_exe_'; + + Future<void> loadPersistedOverrides() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys().where((k) => k.startsWith(_prefsPrefix)); + for (final key in keys) { + final gameId = key.substring(_prefsPrefix.length); + final path = prefs.getString(key); + if (path != null && path.isNotEmpty) _exeOverrides[gameId] = path; + } + } + + Future<void> setExeOverride(String gameId, String exePath) async { + _exeOverrides[gameId] = exePath; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_prefsPrefix$gameId', exePath); + } + + String? getExeOverride(String gameId) => _exeOverrides[gameId]; + + @override + Future<void> launch(Game game, String romPath) async { + // romPath for Windows games is the extracted game folder + String? exePath = _exeOverrides[game.id]; + + // If stored override no longer exists on disk, discard and auto-detect + if (exePath != null && exePath.isNotEmpty && !await File(exePath).exists()) { + debugPrint('[WindowsStrategy] override exe not found at $exePath, falling back to auto-detect'); + exePath = null; + } + + if (exePath == null || exePath.isEmpty) { + // Auto-detect exe in the game folder + final isDir = await Directory(romPath).exists(); + final searchDir = isDir ? romPath : File(romPath).parent.path; + exePath = await _windowsGameService.findExecutable( + searchDir, + hint: game.name, + ); + } + + if (exePath == null) { + throw Exception( + 'No executable found for ${game.name}. ' + 'Please set the exe path manually.', + ); + } + + debugPrint('[WindowsStrategy] launching: $exePath'); + final process = await Process.start( + exePath, + [], + workingDirectory: File(exePath).parent.path, + ); + + // Wait up to 5 seconds — if process exits that fast it crashed + final exitCode = await process.exitCode + .timeout(const Duration(seconds: 5)) + .catchError((_) => -99999); // timeout = still running = fine + + if (exitCode != -99999 && exitCode != 0) { + debugPrint('[WindowsStrategy] ${game.name} exited with code $exitCode'); + throw Exception( + '${game.name} crashed immediately (exit code $exitCode). ' + 'This is likely due to missing DirectX, Visual C++ redistributables, or other dependencies.', + ); + } + + debugPrint('[WindowsStrategy] ${game.name} still running after 5s — OK'); + } + + @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 new file mode 100644 index 0000000..8d433e8 --- /dev/null +++ b/lib/core/emulator/strategies/xemu_strategy.dart @@ -0,0 +1,40 @@ +import 'dart: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'; + +class XemuStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + XemuStrategy(this._directoryService); + + @override + String get name => 'Xemu'; + + @override + String get emulatorId => 'xemu'; + + @override + List<String> get supportedSlugs => ['xbox']; + + @override + String get windowsExecutable => 'xemu.exe'; + + @override + String get linuxExecutable => 'xemu'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.detached); + } + + @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 new file mode 100644 index 0000000..fac9475 --- /dev/null +++ b/lib/core/emulator/strategies/xenia_strategy.dart @@ -0,0 +1,40 @@ +import 'dart: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'; + +class XeniaStrategy extends EmulatorStrategy { + final DirectoryService _directoryService; + + XeniaStrategy(this._directoryService); + + @override + String get name => 'Xenia Canary'; + + @override + String get emulatorId => 'xenia_canary'; + + @override + List<String> get supportedSlugs => ['xbox360', 'xbla']; + + @override + String get windowsExecutable => 'xenia_canary.exe'; + + @override + String get linuxExecutable => 'xenia_canary'; + + @override + bool get supportsSaveSync => false; + + @override + Future<void> 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.start(exePath, [romPath], mode: ProcessStartMode.detached); + } + + @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 57271de..8c46f8f 100644 --- a/lib/core/emulator/strategy_registry.dart +++ b/lib/core/emulator/strategy_registry.dart @@ -1,10 +1,18 @@ -import 'package:flutter/foundation.dart'; // For kIsWeb +import 'package:flutter/foundation.dart'; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/emulator/strategies/retroarch_strategy.dart'; import 'package:freegosy/core/emulator/strategies/dolphin_strategy.dart'; import 'package:freegosy/core/emulator/strategies/eden_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/rpcs3_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/pcsx2_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/azahar_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/cemu_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/duckstation_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/xemu_strategy.dart'; +import 'package:freegosy/core/emulator/strategies/xenia_strategy.dart'; import 'package:freegosy/core/emulator/emulator_registry_data.dart'; import 'package:freegosy/core/storage/directory_service.dart'; +import 'package:freegosy/core/emulator/strategies/windows_strategy.dart'; class StrategyRegistry { final DirectoryService _directoryService; @@ -15,14 +23,19 @@ class StrategyRegistry { RetroArchStrategy(_directoryService), DolphinStrategy(_directoryService), EdenStrategy(_directoryService), + Rpcs3Strategy(_directoryService), + Pcsx2Strategy(_directoryService), + AzaharStrategy(_directoryService), + CemuStrategy(_directoryService), + DuckstationStrategy(_directoryService), + XemuStrategy(_directoryService), + XeniaStrategy(_directoryService), + WindowsStrategy(_directoryService), ]; } EmulatorStrategy? getStrategyForSlug(String platformSlug) { - if (kIsWeb) { - return null; - } - + if (kIsWeb) return null; for (final strategy in _strategies) { if (strategy.supportedSlugs.contains(platformSlug)) { return strategy; @@ -38,4 +51,4 @@ class StrategyRegistry { return null; } } -} +} \ No newline at end of file diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart index f9d24d3..d7806f9 100644 --- a/lib/core/save/save_sync_service.dart +++ b/lib/core/save/save_sync_service.dart @@ -1,5 +1,5 @@ import 'dart:io'; -import 'package:flutter/rendering.dart'; +import 'package:flutter/foundation.dart'; import '../romm/romm_models.dart'; import '../romm/romm_service.dart'; import '../storage/directory_service.dart'; @@ -8,6 +8,10 @@ import 'strategies/retroarch_save_strategy.dart'; import 'strategies/dolphin_save_strategy.dart'; import 'strategies/eden_save_strategy.dart'; import 'package:archive/archive_io.dart'; +import 'strategies/windows_save_strategy.dart'; +import 'strategies/pcsx2_save_strategy.dart'; +import 'strategies/rpcs3_save_strategy.dart'; +import 'strategies/xenia_save_strategy.dart'; class SaveSyncService { final RommService _rommService; @@ -16,11 +20,18 @@ class SaveSyncService { late final RetroArchSaveStrategy _retroarch; late final DolphinSaveStrategy _dolphin; late final EdenSaveStrategy _eden; + late final WindowsSaveStrategy _windows; + late final Pcsx2SaveStrategy _pcsx2; + late final Rpcs3SaveStrategy _rpcs3; + late final XeniaSaveStrategy _xenia; SaveSyncService(this._rommService, this._directoryService) { _retroarch = RetroArchSaveStrategy(_directoryService); _dolphin = DolphinSaveStrategy(_directoryService); _eden = EdenSaveStrategy(); + _windows = WindowsSaveStrategy(); + _pcsx2 = Pcsx2SaveStrategy(_directoryService); + _rpcs3 = Rpcs3SaveStrategy(); } /// Returns the appropriate save strategy for [platformSlug], or null if unsupported. @@ -43,15 +54,43 @@ class SaveSyncService { case 'genesis': case 'md': return _retroarch; + case 'gc': case 'ngc': case 'gamecube': case 'wii': - case 'gc': return _dolphin; case 'switch': case 'nintendo-switch': case 'ns': return _eden; + case 'windows': + case 'pc': + case 'win': + return _windows; + // These emulators don't have save sync yet + case 'ps2': + case 'playstation-2': + case 'playstation2': + return _pcsx2; + case 'ps3': + case 'playstation-3': + case 'playstation3': + return _rpcs3; + case 'xbox360': + case 'xbla': + return _xenia; + case '3ds': + case 'n3ds': + case 'nintendo-3ds': + case 'nintendo3ds': + case 'new-nintendo-3ds': + case 'new-nintendo-3ds-xl': + case 'wiiu': + case 'wii-u': + case 'nintendo-wii-u': + case 'nintendo-wiiu': + case 'xbox': + return null; default: return null; } @@ -150,4 +189,6 @@ class SaveSyncService { rethrow; } } + + WindowsSaveStrategy get windowsSaveStrategy => _windows; } diff --git a/lib/core/save/strategies/pcsx2_save_strategy.dart b/lib/core/save/strategies/pcsx2_save_strategy.dart new file mode 100644 index 0000000..a042690 --- /dev/null +++ b/lib/core/save/strategies/pcsx2_save_strategy.dart @@ -0,0 +1,89 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import '../../romm/romm_models.dart'; +import '../../storage/directory_service.dart'; +import '../save_strategy.dart'; + +/// Save strategy for PCSX2 (PlayStation 2). +/// Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2 +/// States: {emulatorDir}/sstates/{stem}.000 etc. +class Pcsx2SaveStrategy extends SaveStrategy { + final DirectoryService _directoryService; + + Pcsx2SaveStrategy(this._directoryService); + + @override + String get strategyId => 'pcsx2'; + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + final exePath = await _directoryService.findEmulatorExecutable( + 'pcsx2', 'pcsx2-qt.exe'); + if (exePath == null) return null; + return '${File(exePath).parent.path}/memcards'; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final exePath = await _directoryService.findEmulatorExecutable( + 'pcsx2', 'pcsx2-qt.exe'); + if (exePath == null) return []; + final exeDir = File(exePath).parent.path; + + final candidates = <File>[]; + + // Memory cards — shared across all PS2 games + if (syncMode == 'saves' || syncMode == 'both') { + candidates.add(File('$exeDir/memcards/Mcd001.ps2')); + candidates.add(File('$exeDir/memcards/Mcd002.ps2')); + } + + // Save states — named after ROM stem + if (syncMode == 'states' || syncMode == 'both') { + final stem = getRomStem(game); + final statesDir = '$exeDir/sstates'; + for (int i = 0; i <= 9; i++) { + candidates.add(File('$statesDir/$stem.${'$i'.padLeft(3, '0')}')); + } + } + + final result = <File>[]; + for (final f in candidates) { + if (!await f.exists()) continue; + if (sessionStart != null) { + final stat = await f.stat(); + if (stat.modified.isBefore(sessionStart)) continue; + } + result.add(f); + } + return result; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final exePath = await _directoryService.findEmulatorExecutable( + 'pcsx2', 'pcsx2-qt.exe'); + if (exePath == null) return false; + final exeDir = File(exePath).parent.path; + + final isState = filename.contains('.') && + int.tryParse(filename.split('.').last) != null; + final targetDir = isState ? '$exeDir/sstates' : '$exeDir/memcards'; + + final dir = Directory(targetDir); + if (!await dir.exists()) await dir.create(recursive: true); + + final targetPath = '$targetDir/$filename'; + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + debugPrint('[Pcsx2SaveStrategy] restored $filename to $targetPath'); + return true; + } catch (e) { + debugPrint('[Pcsx2SaveStrategy] restoreSave error: $e'); + return false; + } + } +} \ No newline at end of file diff --git a/lib/core/save/strategies/rpcs3_save_strategy.dart b/lib/core/save/strategies/rpcs3_save_strategy.dart new file mode 100644 index 0000000..3361212 --- /dev/null +++ b/lib/core/save/strategies/rpcs3_save_strategy.dart @@ -0,0 +1,122 @@ +import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import '../../romm/romm_models.dart'; +import '../save_strategy.dart'; + +/// Save strategy for RPCS3 (PlayStation 3). +/// Saves: %APPDATA%\rpcs3\dev_hdd0\home00000001円\savedata\{titleId}\ +class Rpcs3SaveStrategy extends SaveStrategy { + @override + String get strategyId => 'rpcs3'; + + Future<String?> _getRpcs3SaveRoot() 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\\rpcs3\\dev_hdd0\\home\00000001円\\savedata'; + } catch (e) { + debugPrint('[Rpcs3SaveStrategy] failed to get APPDATA: $e'); + return null; + } + } + + /// PS3 title IDs are in the ROM folder name e.g. BLES01234 + String? _extractTitleId(Game game) { + final name = game.fsName ?? game.fileName ?? game.name; + final match = RegExp(r'[A-Z]{4}\d{5}').firstMatch(name.toUpperCase()); + return match?.group(0); + } + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + final saveRoot = await _getRpcs3SaveRoot(); + if (saveRoot == null) return null; + final titleId = _extractTitleId(game); + if (titleId == null) return null; + return '$saveRoot\\$titleId'; + } + + @override + Future<List<File>> getSaveFiles(Game game, String romPath, + {DateTime? sessionStart, String syncMode = 'both'}) async { + final saveRoot = await _getRpcs3SaveRoot(); + if (saveRoot == null) return []; + + final titleId = _extractTitleId(game); + if (titleId == null) { + debugPrint('[Rpcs3SaveStrategy] could not extract title ID for ${game.name}'); + return []; + } + + final saveDir = Directory('$saveRoot\\$titleId'); + if (!await saveDir.exists()) return []; + + // Package entire save folder as zip (same approach as Windows saves) + bool hasFiles = false; + await for (final entity in saveDir.list(recursive: true)) { + if (entity is! File) continue; + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; + } + hasFiles = true; + break; + } + if (!hasFiles) return []; + + final zipPath = '${saveDir.path}.saves.zip'; + final encoder = ZipFileEncoder(); + encoder.create(zipPath); + await encoder.addDirectory(saveDir); + encoder.close(); + + debugPrint('[Rpcs3SaveStrategy] packaged saves to $zipPath'); + return [File(zipPath)]; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final saveRoot = await _getRpcs3SaveRoot(); + if (saveRoot == null) return false; + + final titleId = _extractTitleId(game); + if (titleId == null) return false; + + final saveDir = '$saveRoot\\$titleId'; + await Directory(saveDir).create(recursive: true); + + if (filename.toLowerCase().endsWith('.zip')) { + final archive = ZipDecoder().decodeBytes(data); + for (final entry in archive) { + final entryPath = '$saveDir\\${entry.name}'; + if (entry.isFile) { + await backupSave(entryPath); + final outFile = File(entryPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } else { + await Directory(entryPath).create(recursive: true); + } + } + debugPrint('[Rpcs3SaveStrategy] extracted $filename to $saveDir'); + return true; + } + + final targetPath = '$saveDir\\$filename'; + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + debugPrint('[Rpcs3SaveStrategy] restored $filename to $targetPath'); + return true; + } catch (e) { + debugPrint('[Rpcs3SaveStrategy] restoreSave error: $e'); + return false; + } + } +} \ No newline at end of file diff --git a/lib/core/save/strategies/windows_save_strategy.dart b/lib/core/save/strategies/windows_save_strategy.dart new file mode 100644 index 0000000..68dfc3c --- /dev/null +++ b/lib/core/save/strategies/windows_save_strategy.dart @@ -0,0 +1,135 @@ +import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../romm/romm_models.dart'; +import '../../windows/pcgamingwiki_service.dart'; +import '../save_strategy.dart'; +import 'package:dio/dio.dart'; + +class WindowsSaveStrategy extends SaveStrategy { + final PcGamingWikiService _wikiService; + + // Manual override paths set by user per game id + final Map<String, String> _manualOverrides = {}; + + WindowsSaveStrategy() : _wikiService = PcGamingWikiService(Dio()); + + @override + String get strategyId => 'windows'; + + /// Allows the user to manually set a save path for a game. + static const String _prefsPrefix = 'win_save_'; + + Future<void> loadPersistedOverrides() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys().where((k) => k.startsWith(_prefsPrefix)); + for (final key in keys) { + final gameId = key.substring(_prefsPrefix.length); + final path = prefs.getString(key); + if (path != null && path.isNotEmpty) _manualOverrides[gameId] = path; + } + } + + /// Allows the user to manually set a save path for a game. + Future<void> setManualOverride(String gameId, String path) async { + _manualOverrides[gameId] = path; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_prefsPrefix$gameId', path); + } + + String? getManualOverride(String gameId) => _manualOverrides[gameId]; + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + // Manual override takes priority + final manual = _manualOverrides[game.id]; + if (manual != null && manual.isNotEmpty) return manual; + + // Try PCGamingWiki + try { + final locations = await _wikiService.getSaveLocations(game.name); + if (locations.isNotEmpty) { + debugPrint('[WindowsSaveStrategy] wiki found ${locations.length} locations for ${game.name}'); + return locations.first['path']; + } + } catch (e) { + debugPrint('[WindowsSaveStrategy] wiki lookup failed: $e'); + } + + return null; + } + + @override + Future<List<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 []; + + // Check if any files exist (respecting sessionStart) + bool hasFiles = false; + await for (final entity in dir.list(recursive: true)) { + if (entity is! File) continue; + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; + } + hasFiles = true; + break; + } + if (!hasFiles) return []; + + // Package the entire save directory as a single zip + final zipPath = '${dir.path}.saves.zip'; + final encoder = ZipFileEncoder(); + encoder.create(zipPath); + await encoder.addDirectory(dir); + encoder.close(); + + debugPrint('[WindowsSaveStrategy] packaged saves to $zipPath'); + return [File(zipPath)]; + } + + @override + Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async { + try { + final saveDir = await getSaveDir(game, destPath); + if (saveDir == null) { + throw Exception('No save location found for ${game.name}. Set one manually in game settings.'); + } + + final dir = Directory(saveDir); + if (!await dir.exists()) await dir.create(recursive: true); + + // Always extract zip into the save directory + if (filename.toLowerCase().endsWith('.zip')) { + final archive = ZipDecoder().decodeBytes(data); + for (final entry in archive) { + final entryPath = '$saveDir/${entry.name}'; + if (entry.isFile) { + await backupSave(entryPath); + final outFile = File(entryPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } else { + await Directory(entryPath).create(recursive: true); + } + } + debugPrint('[WindowsSaveStrategy] extracted $filename to $saveDir'); + return true; + } + + // Fallback for non-zip (single file) + final targetPath = '$saveDir/$filename'; + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + debugPrint('[WindowsSaveStrategy] restored $filename to $targetPath'); + return true; + } catch (e) { + debugPrint('[WindowsSaveStrategy] restoreSave error: $e'); + rethrow; + } + } +} diff --git a/lib/core/save/strategies/xenia_save_strategy.dart b/lib/core/save/strategies/xenia_save_strategy.dart new file mode 100644 index 0000000..7958407 --- /dev/null +++ b/lib/core/save/strategies/xenia_save_strategy.dart @@ -0,0 +1,109 @@ +import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import '../../romm/romm_models.dart'; +import '../../storage/directory_service.dart'; +import '../save_strategy.dart'; + +/// Save strategy for Xenia Canary (Xbox 360). +/// Saves: {emulatorDir}\content\{titleId}00000001円\ +class XeniaSaveStrategy extends SaveStrategy { + final DirectoryService _directoryService; + + XeniaSaveStrategy(this._directoryService); + + @override + String get strategyId => 'xenia_canary'; + + /// Xbox 360 title IDs are 8 hex characters e.g. 4D5307E6 + String? _extractTitleId(Game game) { + final name = game.fsName ?? game.fileName ?? game.name; + final match = RegExp(r'[0-9A-Fa-f]{8}').firstMatch(name); + return match?.group(0)?.toUpperCase(); + } + + Future<String?> _getContentDir() async { + final exePath = await _directoryService.findEmulatorExecutable( + 'xenia_canary', 'xenia_canary.exe'); + if (exePath == null) return null; + return '${File(exePath).parent.path}\\content'; + } + + @override + Future<String?> getSaveDir(Game game, String romPath) async { + final contentDir = await _getContentDir(); + if (contentDir == null) return null; + final titleId = _extractTitleId(game); + if (titleId == null) return null; + return '$contentDir\\$titleId\00000001円'; + } + + @override + Future<List<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 []; + + bool hasFiles = false; + await for (final entity in dir.list(recursive: true)) { + if (entity is! File) continue; + if (sessionStart != null) { + final stat = await entity.stat(); + if (stat.modified.isBefore(sessionStart)) continue; + } + hasFiles = true; + break; + } + if (!hasFiles) return []; + + // Package entire save folder as zip + final zipPath = '${dir.path}.saves.zip'; + final encoder = ZipFileEncoder(); + encoder.create(zipPath); + await encoder.addDirectory(dir); + encoder.close(); + + debugPrint('[XeniaSaveStrategy] packaged saves to $zipPath'); + return [File(zipPath)]; + } + + @override + Future<bool> restoreSave( + Game game, String destPath, Uint8List data, String filename) async { + try { + final saveDir = await getSaveDir(game, destPath); + if (saveDir == null) return false; + + await Directory(saveDir).create(recursive: true); + + if (filename.toLowerCase().endsWith('.zip')) { + final archive = ZipDecoder().decodeBytes(data); + for (final entry in archive) { + final entryPath = '$saveDir\\${entry.name}'; + if (entry.isFile) { + await backupSave(entryPath); + final outFile = File(entryPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(entry.content as List<int>); + } else { + await Directory(entryPath).create(recursive: true); + } + } + debugPrint('[XeniaSaveStrategy] extracted $filename to $saveDir'); + return true; + } + + final targetPath = '$saveDir\\$filename'; + await backupSave(targetPath); + await File(targetPath).writeAsBytes(data); + debugPrint('[XeniaSaveStrategy] restored $filename to $targetPath'); + return true; + } catch (e) { + debugPrint('[XeniaSaveStrategy] restoreSave error: $e'); + 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 303cfc9..2ee7855 100644 --- a/lib/core/storage/directory_service.dart +++ b/lib/core/storage/directory_service.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:freegosy/core/romm/romm_models.dart'; @@ -95,7 +97,11 @@ class DirectoryService { final folderName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); final multiFileDir = Directory('$romDir/$folderName'); if (await multiFileDir.exists()) { - // Find largest file inside — that's the main ROM + // Windows games: return the folder itself so WindowsStrategy can find the exe + final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); + if (isWindowsGame) return multiFileDir.path; + + // Other platforms: find largest file inside — that's the main ROM File? largestFile; int largestSize = 0; await for (final entity in multiFileDir.list(recursive: true)) { @@ -135,6 +141,40 @@ class DirectoryService { return null; } + Future<String?> resolveSevenZipPath() async { + String appData = ''; + + if (defaultTargetPlatform == TargetPlatform.windows) { + try { + final result = await Process.run('cmd', ['/c', 'echo %APPDATA%'], runInShell: false); + appData = result.stdout.toString().trim(); + } catch (e) { + debugPrint('[DirectoryService] failed to get APPDATA: $e'); + return null; + } + if (appData.isEmpty || appData.contains('%APPDATA%')) return null; + } else { + // macOS/Linux: no 7zr needed, system 7z is installable via package managers + // Return null for now — future: resolve via `which 7z` + return null; + } + + final dest = File('$appData\\Freegosy\\thirdparty\7円zr.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()); + debugPrint('[DirectoryService] 7zr.exe extracted to ${dest.path}'); + return dest.path; + } catch (e) { + debugPrint('[DirectoryService] failed to extract 7zr.exe: $e'); + return null; + } + } + Future<String> getEmulatorDirectory(String emulatorId) async { final dirPath = '$emulatorsRootPath/$emulatorId'; await _ensureDirectoryExists(dirPath); diff --git a/lib/core/windows/pcgamingwiki_service.dart b/lib/core/windows/pcgamingwiki_service.dart new file mode 100644 index 0000000..bb74ec2 --- /dev/null +++ b/lib/core/windows/pcgamingwiki_service.dart @@ -0,0 +1,202 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:io'; + +class PcGamingWikiService { + final Dio _dio; + + PcGamingWikiService(this._dio); + + static const String _apiUrl = 'https://www.pcgamingwiki.com/w/api.php'; + + /// Finds the PCGamingWiki page title for [gameTitle]. + Future<String?> findPageTitle(String gameTitle) async { + try { + // Try exact match first + final exactResponse = await _dio.get(_apiUrl, queryParameters: { + 'action': 'query', + 'titles': gameTitle, + 'format': 'json', + }); + + if (exactResponse.statusCode == 200) { + final pages = exactResponse.data['query']['pages'] as Map; + for (final pageId in pages.keys) { + if (pageId != '-1') return pages[pageId]['title'] as String; + } + } + + // Fall back to search + final searchResponse = await _dio.get(_apiUrl, queryParameters: { + 'action': 'query', + 'list': 'search', + 'srsearch': gameTitle, + 'format': 'json', + }); + + if (searchResponse.statusCode == 200) { + final results = searchResponse.data['query']['search'] as List; + if (results.isNotEmpty) return results.first['title'] as String; + } + } catch (e) { + debugPrint('[PcGamingWiki] findPageTitle error: $e'); + } + return null; + } + + /// Fetches the wikitext for [pageTitle]. + Future<String?> getWikitext(String pageTitle) async { + try { + final response = await _dio.get(_apiUrl, queryParameters: { + 'action': 'parse', + 'page': pageTitle, + 'prop': 'wikitext', + 'format': 'json', + }); + if (response.statusCode == 200) { + return response.data['parse']['wikitext']['*'] as String?; + } + } catch (e) { + debugPrint('[PcGamingWiki] getWikitext error: $e'); + } + return null; + } + + /// Returns expanded save locations for [gameTitle]. + Future<List<Map<String, String>>> getSaveLocations(String gameTitle, {String gameDir = ''}) async { + try { + final pageTitle = await findPageTitle(gameTitle); + if (pageTitle == null) return []; + + final wikitext = await getWikitext(pageTitle); + if (wikitext == null) return []; + + return _parseSaveLocations(wikitext, gameTitle, gameDir); + } catch (e) { + debugPrint('[PcGamingWiki] getSaveLocations error: $e'); + return []; + } + } + + List<Map<String, String>> _parseSaveLocations(String wikitext, String gameTitle, String gameDir) { + final results = <Map<String, String>>[]; + final seen = <String>{}; + + for (final line in wikitext.split('\n')) { + if (!line.contains('Game data/saves')) continue; + if (!line.contains('|Windows|')) continue; + + try { + final after = line.split('|Windows|').last; + final cleaned = after.endsWith('}}') ? after.substring(0, after.length - 2) : after; + final paths = _safeSplitPaths(cleaned.trim()); + + for (final raw in paths) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) continue; + + final lower = trimmed.toLowerCase(); + if (_shouldSkipPath(lower)) continue; + + final expanded = _expandWikiPath(trimmed, gameTitle, gameDir); + if (expanded == null) continue; + + final normalizedLower = expanded.toLowerCase(); + if (seen.contains(normalizedLower)) continue; + seen.add(normalizedLower); + + results.add({ + 'raw': trimmed, + 'path': expanded, + }); + } + } catch (e) { + debugPrint('[PcGamingWiki] parse error on line: $e'); + } + } + return results; + } + + bool _shouldSkipPath(String lower) { + return lower.contains('steam') || + lower.contains('linux') || + lower.contains('wine') || + lower.contains('{{p|uid}}') || + lower.contains('{{p|hkcu}}') || + lower.contains('{{p|osxhome}}') || + lower.contains('{{p|xdg') || + lower.contains('{{p|linux'); + } + + List<String> _safeSplitPaths(String s) { + final parts = <String>[]; + int depth = 0; + final current = StringBuffer(); + int i = 0; + while (i < s.length) { + if (i + 1 < s.length && s[i] == '{' && s[i + 1] == '{') { + depth++; + current.write('{{'); + i += 2; + continue; + } + if (i + 1 < s.length && s[i] == '}' && s[i + 1] == '}') { + depth--; + current.write('}}'); + i += 2; + continue; + } + if (s[i] == '|' && depth == 0) { + parts.add(current.toString().trim()); + current.clear(); + i++; + continue; + } + current.write(s[i]); + i++; + } + if (current.isNotEmpty) parts.add(current.toString().trim()); + return parts.where((p) => p.isNotEmpty).toList(); + } + + String? _expandWikiPath(String path, String gameTitle, String gameDir) { + final appData = Platform.environment['APPDATA'] ?? ''; + final localAppData = Platform.environment['LOCALAPPDATA'] ?? ''; + final userProfile = Platform.environment['USERPROFILE'] ?? ''; + final programData = Platform.environment['PROGRAMDATA'] ?? ''; + final public = Platform.environment['PUBLIC'] ?? ''; + + final subs = <String, String>{ + '{{p|appdata}}': appData, + '{{p|localappdata}}': localAppData, + '{{p|userprofile}}': userProfile, + '{{p|programdata}}': programData, + '{{p|public}}': public, + '{{p|game}}': gameDir.isNotEmpty ? '$gameDir/$gameTitle' : '', + }; + + String expanded = path; + for (final entry in subs.entries) { + if (entry.value.isEmpty && expanded.toLowerCase().contains(entry.key)) { + return null; + } + expanded = expanded.replaceAll( + RegExp(RegExp.escape(entry.key), caseSensitive: false), + entry.value, + ); + } + + // If any unresolved templates remain, skip + if (expanded.toLowerCase().contains('{{p|')) return null; + + // Strip wildcard filenames e.g. \*.dat + expanded = expanded.replaceAll(RegExp(r'[/\\]\*\.[a-zA-Z0-9]+$'), ''); + + // Strip bare filenames with extension at end + if (RegExp(r'\.[a-zA-Z0-9]{2,4}$').hasMatch(expanded)) { + expanded = File(expanded).parent.path; + } + + return expanded.replaceAll('/', '\\'); + } +} \ No newline at end of file diff --git a/lib/core/windows/windows_game_service.dart b/lib/core/windows/windows_game_service.dart new file mode 100644 index 0000000..db76fb2 --- /dev/null +++ b/lib/core/windows/windows_game_service.dart @@ -0,0 +1,73 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +class WindowsGameService { + /// Finds the main executable in [gameDir]. + /// First tries to match [hint] (game name), then falls back to largest .exe. + Future<String?> findExecutable(String gameDir, {String? hint}) async { + final dir = Directory(gameDir); + if (!await dir.exists()) return null; + + final exeFiles = <File>[]; + await for (final entity in dir.list(recursive: true)) { + if (entity is File && entity.path.toLowerCase().endsWith('.exe')) { + // Skip known non-game executables + final name = p.basename(entity.path).toLowerCase(); + if (_shouldSkipExe(name)) continue; + exeFiles.add(entity); + } + } + + if (exeFiles.isEmpty) return null; + + // Try hint match first (game name similarity) + if (hint != null) { + final hintLower = hint.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + for (final exe in exeFiles) { + final exeName = p.basenameWithoutExtension(exe.path) + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]'), ''); + if (exeName.contains(hintLower) || hintLower.contains(exeName)) { + debugPrint('[WindowsGameService] matched exe by hint: ${exe.path}'); + return exe.path; + } + } + } + + // Fall back to largest exe + File? largest; + int largestSize = 0; + for (final exe in exeFiles) { + final size = await exe.length(); + if (size > largestSize) { + largestSize = size; + largest = exe; + } + } + + debugPrint('[WindowsGameService] largest exe: ${largest?.path}'); + return largest?.path; + } + + bool _shouldSkipExe(String name) { + const skipList = [ + 'uninstall', 'uninst', 'setup', 'install', 'redist', + 'vc_redist', 'vcredist', 'directx', 'dxsetup', + 'dotnet', 'crashreport', 'crashhandler', 'bugsplat', + 'upc', 'easyanticheat', 'battleye', 'launcher_helper', + ]; + return skipList.any((s) => name.contains(s)); + } + + /// Launches the exe at [exePath]. + Future<void> launch(String exePath) async { + final dir = File(exePath).parent.path; + await Process.start( + exePath, + [], + workingDirectory: dir, + mode: ProcessStartMode.detached, + ); + } +} \ No newline at end of file diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart index 6c14d0c..989865d 100644 --- a/lib/providers/romm_provider.dart +++ b/lib/providers/romm_provider.dart @@ -6,6 +6,8 @@ import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/romm/romm_service.dart'; import 'package:freegosy/core/emulator/strategy_registry.dart'; import 'package:freegosy/core/save/save_sync_service.dart'; +import 'package:freegosy/core/emulator/strategies/windows_strategy.dart'; + // Provider for loading RomMConfig (including stored Bearer token) final rommConfigProvider = FutureProvider<RomMConfig>((ref) async { @@ -44,7 +46,13 @@ final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) { final directoryService = ref.watch(directoryServiceProvider).value; if (directoryService != null) { try { - return StrategyRegistry(directoryService); + final registry = StrategyRegistry(directoryService); + // Load persisted Windows exe overrides + final winStrategy = registry.getStrategyForSlug('windows'); + if (winStrategy is WindowsStrategy) { + winStrategy.loadPersistedOverrides(); + } + return registry; } catch (e) { return null; } @@ -57,7 +65,10 @@ final saveSyncServiceProvider = Provider<SaveSyncService?>((ref) { final rommService = ref.watch(rommServiceProvider); final directoryService = ref.watch(directoryServiceProvider).asData?.value; if (rommService == null || directoryService == null) return null; - return SaveSyncService(rommService, directoryService); + final service = SaveSyncService(rommService, directoryService); + // Load persisted Windows save path overrides + service.windowsSaveStrategy.loadPersistedOverrides(); + return service; }); // Simplified RommService provider diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index 1888a83..25155c5 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -6,6 +6,8 @@ import '../../providers/romm_provider.dart'; import '../../core/romm/romm_models.dart'; import '../widgets/game_card.dart'; import '../widgets/platform_filter_bar.dart'; +import '../widgets/windows_game_config_dialog.dart'; +import '../../core/emulator/strategies/windows_strategy.dart'; import 'dart:convert'; class LibraryScreen extends ConsumerWidget { @@ -124,17 +126,52 @@ class LibraryScreen extends ConsumerWidget { await strategy.launch(game, existingRomPath); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Launch failed: $e')), - ); + final isWindows = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); + final isMissingExe = e.toString().contains('No executable') || e.toString().contains('not found'); + if (isWindows && isMissingExe) { + await _handleWindowsConfig(context, ref, game); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Launch failed: $e'), + duration: const Duration(seconds: 8), + ), + ); + } } } + Future<void> _handleWindowsConfig(BuildContext context, WidgetRef ref, Game game) async { + final registry = ref.read(strategyRegistryProvider); + final windowsStrategy = registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?; + final syncService = ref.read(saveSyncServiceProvider); + + final result = await showDialog<Map<String, String>>( + context: context, + builder: (ctx) => WindowsGameConfigDialog( + game: game, + currentExePath: windowsStrategy?.getExeOverride(game.id), + currentSavePath: syncService?.windowsSaveStrategy.getManualOverride(game.id), + ), + ); + if (result == null) return; // user cancelled + + final exe = result['exe'] ?? ''; + final save = result['save'] ?? ''; + + if (exe.isNotEmpty) await windowsStrategy?.setExeOverride(game.id, exe); + if (save.isNotEmpty) await syncService?.windowsSaveStrategy.setManualOverride(game.id, save); + + if (!context.mounted) return; + await _handleLaunch(context, ref, game); + } + Future<void> _handleSyncSaves(BuildContext context, WidgetRef ref, Game game) async { final syncService = ref.read(saveSyncServiceProvider); if (syncService == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Save sync not available')), + const SnackBar( + content: Text('Save sync not available')), ); return; } @@ -276,23 +313,30 @@ class LibraryScreen extends ConsumerWidget { itemBuilder: (context, index) { final game = filteredGames[index]; final dirService = directoryServiceAsync.asData?.value; + final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); if (dirService == null) { - return GameCard( - game: game, - onDownload: () => _startDownload(context, ref, game), - onLaunch: () => _handleLaunch(context, ref, game), - onSyncSaves: () => _handleSyncSaves(context, ref, game), + return GestureDetector( + onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null, + child: GameCard( + game: game, + onDownload: () => _startDownload(context, ref, game), + onLaunch: () => _handleLaunch(context, ref, game), + onSyncSaves: () => _handleSyncSaves(context, ref, game), + ), ); } return FutureBuilder<bool>( future: dirService.isRomDownloaded(game), builder: (context, snapshot) { - return GameCard( - game: game, - isDownloaded: snapshot.data ?? false, - onDownload: () => _startDownload(context, ref, game), - onLaunch: () => _handleLaunch(context, ref, game), - onSyncSaves: () => _handleSyncSaves(context, ref, game), + return GestureDetector( + onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null, + child: GameCard( + game: game, + isDownloaded: snapshot.data ?? false, + onDownload: () => _startDownload(context, ref, game), + onLaunch: () => _handleLaunch(context, ref, game), + onSyncSaves: () => _handleSyncSaves(context, ref, game), + ), ); }, ); diff --git a/lib/ui/widgets/windows_game_config_dialog.dart b/lib/ui/widgets/windows_game_config_dialog.dart new file mode 100644 index 0000000..e2863f8 --- /dev/null +++ b/lib/ui/widgets/windows_game_config_dialog.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import '../../core/romm/romm_models.dart'; + +class WindowsGameConfigDialog extends StatefulWidget { + final Game game; + final String? currentExePath; + final String? currentSavePath; + + const WindowsGameConfigDialog({ + super.key, + required this.game, + this.currentExePath, + this.currentSavePath, + }); + + @override + State<WindowsGameConfigDialog> createState() => _WindowsGameConfigDialogState(); +} + +class _WindowsGameConfigDialogState extends State<WindowsGameConfigDialog> { + late TextEditingController _exeController; + late TextEditingController _saveController; + + @override + void initState() { + super.initState(); + _exeController = TextEditingController(text: widget.currentExePath ?? ''); + _saveController = TextEditingController(text: widget.currentSavePath ?? ''); + } + + @override + void dispose() { + _exeController.dispose(); + _saveController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Configure ${widget.game.name}'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Executable (.exe)', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: TextField( + controller: _exeController, + decoration: const InputDecoration( + hintText: 'Auto-detect or browse...', + border: OutlineInputBorder(), + isDense: true, + ), + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['exe'], + ); + if (result != null && result.files.single.path != null) { + setState(() { + _exeController.text = result.files.single.path!; + }); + } + }, + child: const Text('Browse'), + ), + ], + ), + const SizedBox(height: 16), + const Text( + 'Save Directory', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + const Text( + 'Leave empty to use PCGamingWiki auto-detection', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: TextField( + controller: _saveController, + decoration: const InputDecoration( + hintText: 'Auto-detect or browse...', + border: OutlineInputBorder(), + isDense: true, + ), + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () async { + final path = await FilePicker.platform.getDirectoryPath(); + if (path != null) { + setState(() { + _saveController.text = path; + }); + } + }, + child: const Text('Browse'), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop({ + 'exe': _exeController.text.trim(), + 'save': _saveController.text.trim(), + }), + child: const Text('Save'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 8f7eef8..ebbfe54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,7 +59,8 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - + assets: + - thirdparty/7zr.exe # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/thirdparty/7zr.exe b/thirdparty/7zr.exe new file mode 100644 index 0000000..2934156 Binary files /dev/null and b/thirdparty/7zr.exe differ