diff --git a/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate index 506c634a1..1fbaf0d30 100644 Binary files a/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate and b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift index a4911b829..2083e9e82 100644 --- a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift @@ -58,6 +58,7 @@ class Ryujinx { @Published var metalLayer: CAMetalLayer? = nil @Published var firmwareversion = "0" @Published var emulationUIView = UIView() + @Published var games: [Game] = [] var shouldMetal: Bool { metalLayer == nil @@ -65,7 +66,9 @@ class Ryujinx { static let shared = Ryujinx() - private init() {} + private init() { + self.games = loadGames() + } public struct Configuration : Codable, Equatable { var gamepath: String @@ -202,6 +205,53 @@ class Ryujinx { } } } + + func loadGames() -> [Game] { + let fileManager = FileManager.default + guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return [] } + + let romsDirectory = documentsDirectory.appendingPathComponent("roms") + + if (!fileManager.fileExists(atPath: romsDirectory.path)) { + do { + try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + print("Failed to create roms directory: \(error)") + } + } + var games: [Game] = [] + + do { + let files = try fileManager.contentsOfDirectory(at: romsDirectory, includingPropertiesForKeys: nil) + + for fileURLCandidate in files { + if fileURLCandidate.pathExtension == "zip" { + continue + } + + do { + let handle = try FileHandle(forReadingFrom: fileURLCandidate) + let fileExtension = (fileURLCandidate.pathExtension as NSString).utf8String + let extensionPtr = UnsafeMutablePointer(mutating: fileExtension) + + + let gameInfo = get_game_info(handle.fileDescriptor, extensionPtr) + + let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: fileURLCandidate) + + games.append(game) + } catch { + print(error) + } + } + + return games + } catch { + print("Error loading games from roms folder: \(error)") + return games + } + + } private func buildCommandLineArgs(from config: Configuration) -> [String] { var args: [String] = [] diff --git a/src/MeloNX/MeloNX/App/Intents/LaunchGameIntent.swift b/src/MeloNX/MeloNX/App/Intents/LaunchGameIntent.swift new file mode 100644 index 000000000..dcd253aa3 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Intents/LaunchGameIntent.swift @@ -0,0 +1,54 @@ +// +// LaunchGameIntentDef.swift +// MeloNX +// +// Created by Stossy11 on 10/02/2025. +// + + +import Foundation +import SwiftUI +import Intents +import AppIntents + +@available(iOS 16.0, *) +struct LaunchGameIntentDef: AppIntent { + + static let title: LocalizedStringResource = "Launch Game" + + static var description = IntentDescription("Launches the Selected Game.") + + @Parameter(title: "Game", optionsProvider: GameOptionsProvider()) + var gameName: String + + static var parameterSummary: some ParameterSummary { + Summary("Launch \(\.$gameName)") + } + + static var openAppWhenRun: Bool = true + + @MainActor + func perform() async throws -> some IntentResult { + + let ryujinx = Ryujinx.shared.games + + let urlString = "melonx://game?\(ryujinx.contains(where: { $0.titleName.localizedCaseInsensitiveContains(gameName) }) ? "name" : "id")=\(gameName)" + print(urlString) + if let url = URL(string: urlString) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + return .result() + } +} + +@available(iOS 16.0, *) +struct GameOptionsProvider: DynamicOptionsProvider { + func results() async throws -> [String] { + Ryujinx.shared.games = Ryujinx.shared.loadGames() + + let dynamicGames = Ryujinx.shared.games + + return dynamicGames.map { $0.titleName } + } +} diff --git a/src/MeloNX/MeloNX/App/Models/Game.swift b/src/MeloNX/MeloNX/App/Models/Game.swift index ee13dd3f6..d9ed10422 100644 --- a/src/MeloNX/MeloNX/App/Models/Game.swift +++ b/src/MeloNX/MeloNX/App/Models/Game.swift @@ -8,7 +8,7 @@ import SwiftUI import UniformTypeIdentifiers -public struct Game: Identifiable, Equatable { +public struct Game: Identifiable, Equatable, Hashable { public var id = UUID() var containerFolder: URL diff --git a/src/MeloNX/MeloNX/App/Views/ContentView.swift b/src/MeloNX/MeloNX/App/Views/ContentView.swift index 3e9ba4f5d..1112174ab 100644 --- a/src/MeloNX/MeloNX/App/Views/ContentView.swift +++ b/src/MeloNX/MeloNX/App/Views/ContentView.swift @@ -118,6 +118,16 @@ struct ContentView: View { initControllerObservers() // This initializes the Controller Observers that refreshes the controller list when a new controller connecvts. } + .onOpenURL() { url in + if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + components.host == "game" { + if let text = components.queryItems?.first(where: { $0.name == "id" })?.value { + game = Ryujinx.shared.games.first(where: { $0.titleId == text }) + } else if let text = components.queryItems?.first(where: { $0.name == "name" })?.value { + game = Ryujinx.shared.games.first(where: { $0.titleName == text }) + } + } + } } } diff --git a/src/MeloNX/MeloNX/App/Views/GamesList/GameInfoSheet.swift b/src/MeloNX/MeloNX/App/Views/GamesList/GameInfoSheet.swift index 45dea7551..f8fe23945 100644 --- a/src/MeloNX/MeloNX/App/Views/GamesList/GameInfoSheet.swift +++ b/src/MeloNX/MeloNX/App/Views/GamesList/GameInfoSheet.swift @@ -52,6 +52,14 @@ struct GameInfoSheet: View { .bold() Text("**Version:** \(game.version)") + Text("**Title ID:** \(game.titleId)") + .contextMenu { + Button { + UIPasteboard.general.string = game.titleId + } label: { + Text("Copy Title ID") + } + } Text("**Game Size:** \(fetchFileSize(for: game.fileURL) ?? 0) bytes") Text("**File Type:** .\(getFileType(game.fileURL))") Text("**Game URL:** \(trimGameURL(game.fileURL))") diff --git a/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift b/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift index 6ab186c0d..9d22ae22a 100644 --- a/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift +++ b/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift @@ -16,7 +16,6 @@ extension UTType { struct GameLibraryView: View { @Binding var startemu: Game? // @State var importDLCs = false - @State private var games: [Game] = [] @State private var searchText = "" @State private var isSearching = false @AppStorage("recentGames") private var recentGamesData: Data = Data() @@ -29,13 +28,18 @@ struct GameLibraryView: View { @State var isSelectingGameFile = false @State var isViewingGameInfo: Bool = false @State var gameInfo: Game? - + var games: Binding<[Game]> { + Binding( + get: { Ryujinx.shared.games }, + set: { Ryujinx.shared.games = $0 } + ) + } var filteredGames: [Game] { if searchText.isEmpty { - return games + return Ryujinx.shared.games } - return games.filter { + return Ryujinx.shared.games.filter { $0.titleName.localizedCaseInsensitiveContains(searchText) || $0.developer.localizedCaseInsensitiveContains(searchText) } @@ -52,7 +56,7 @@ struct GameLibraryView: View { .padding(.top, 12) } - if games.isEmpty { + if Ryujinx.shared.games.isEmpty { VStack(spacing: 16) { Image(systemName: "gamecontroller.fill") .font(.system(size: 64)) @@ -95,7 +99,7 @@ struct GameLibraryView: View { LazyVStack(spacing: 2) { ForEach(filteredGames) { game in - GameListRow(game: game, startemu: $startemu, games: $games, isViewingGameInfo: $isViewingGameInfo, gameInfo: $gameInfo) + GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, gameInfo: $gameInfo) .onTapGesture { addToRecentGames(game) } @@ -105,7 +109,7 @@ struct GameLibraryView: View { } else { LazyVStack(spacing: 2) { ForEach(filteredGames) { game in - GameListRow(game: game, startemu: $startemu, games: $games, isViewingGameInfo: $isViewingGameInfo, gameInfo: $gameInfo) + GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, gameInfo: $gameInfo) .onTapGesture { addToRecentGames(game) } @@ -115,7 +119,6 @@ struct GameLibraryView: View { } } .onAppear { - loadGames() loadRecentGames() let firmware = Ryujinx.shared.fetchFirmwareVersion() @@ -262,7 +265,7 @@ struct GameLibraryView: View { let destinationURL = romsDirectory.appendingPathComponent(url.lastPathComponent) try fileManager.copyItem(at: url, to: destinationURL) - loadGames() + Ryujinx.shared.games = Ryujinx.shared.loadGames() } catch { print("Error copying game file: \(error)") } @@ -317,56 +320,15 @@ struct GameLibraryView: View { recentGames = [] } } - -// MARK: - loads games from roms - func loadGames() { - let fileManager = FileManager.default - guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - let romsDirectory = documentsDirectory.appendingPathComponent("roms") - - // Check if "roms" folder exists; if not, create it - if (!fileManager.fileExists(atPath: romsDirectory.path)) { - do { - try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil) - } catch { - print("Failed to create roms directory: \(error)") - } - } - games = [] - // Load games only from "roms" folder - do { - let files = try fileManager.contentsOfDirectory(at: romsDirectory, includingPropertiesForKeys: nil) - - files.forEach { fileURLCandidate in - do { - let handle = try FileHandle(forReadingFrom: fileURLCandidate) - let fileExtension = (fileURLCandidate.pathExtension as NSString).utf8String - let extensionPtr = UnsafeMutablePointer(mutating: fileExtension) - - - let gameInfo = get_game_info(handle.fileDescriptor, extensionPtr) - - let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: fileURLCandidate) - - games.append(game) - } catch { - print(error) - } - } - - } catch { - print("Error loading games from roms folder: \(error)") - } - } + // MARK: - Delete Game Function func deleteGame(game: Game) { let fileManager = FileManager.default do { try fileManager.removeItem(at: game.fileURL) - games.removeAll { $0.id == game.id } - loadGames() + Ryujinx.shared.games.removeAll { $0.id == game.id } + Ryujinx.shared.games = Ryujinx.shared.loadGames() } catch { print("Error deleting game: \(error)") } diff --git a/src/MeloNX/MeloNX/Info.plist b/src/MeloNX/MeloNX/Info.plist index d23c87fd9..0cf82243f 100644 --- a/src/MeloNX/MeloNX/Info.plist +++ b/src/MeloNX/MeloNX/Info.plist @@ -2,8 +2,29 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.stossy11.MeloNX + CFBundleURLSchemes + + melonx + + + + LSApplicationQueriesSchemes + + melonx + MeloID 83f67a0a96bd8628a150d7853e360db5bae64e7769524fae399c4b8e7e6aff17 + NSUserActivityTypes + + LaunchGameIntent + UIFileSharingEnabled UTExportedTypeDeclarations diff --git a/src/MeloNX/MeloNX/MeloNXApp.swift b/src/MeloNX/MeloNX/MeloNXApp.swift index 665bf9e4c..f555158b1 100644 --- a/src/MeloNX/MeloNX/MeloNXApp.swift +++ b/src/MeloNX/MeloNX/MeloNXApp.swift @@ -15,7 +15,7 @@ import CryptoKit struct MeloNXApp: App { @State var showed = false - + @Environment(\.scenePhase) var scenePhase var body: some Scene { WindowGroup { @@ -61,7 +61,7 @@ struct MeloNXApp: App { Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in InitializeRyujinx() { bool in - if !bool { + if !bool, (scenePhase != .background || scenePhase == .inactive) { withAnimation { showed = false } @@ -119,7 +119,7 @@ struct MeloNXApp: App { // Present the alert mainWindow.rootViewController!.present(alertController, animated: true, completion: nil) } else { - exit(0) + } } @@ -133,7 +133,7 @@ func showDMCAAlert() { mainWindow.rootViewController!.present(alertController, animated: true, completion: nil) } else { - exit(0) + // uhoh } } }