diff --git a/src/MeloNX/MeloNX.xcodeproj/project.pbxproj b/src/MeloNX/MeloNX.xcodeproj/project.pbxproj index 066587266..3d63a96ad 100644 --- a/src/MeloNX/MeloNX.xcodeproj/project.pbxproj +++ b/src/MeloNX/MeloNX.xcodeproj/project.pbxproj @@ -662,6 +662,8 @@ "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", ); GCC_OPTIMIZATION_LEVEL = fast; GENERATE_INFOPLIST_FILE = YES; @@ -725,6 +727,10 @@ "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", ); MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX; @@ -774,6 +780,8 @@ "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", ); GCC_OPTIMIZATION_LEVEL = fast; GENERATE_INFOPLIST_FILE = YES; @@ -837,6 +845,10 @@ "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", + "$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries", ); MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX; 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 ae985c98a..848e330e7 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.xcodeproj/xcuserdata/stossy11.xcuserdatad/xcschemes/xcschememanagement.plist b/src/MeloNX/MeloNX.xcodeproj/xcuserdata/stossy11.xcuserdatad/xcschemes/xcschememanagement.plist index 8ff6cf524..62375ba69 100644 --- a/src/MeloNX/MeloNX.xcodeproj/xcuserdata/stossy11.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/src/MeloNX/MeloNX.xcodeproj/xcuserdata/stossy11.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,12 +12,12 @@ Ryujinx.xcscheme_^#shared#^_ orderHint - 2 + 1 com.Stossy11.MeloNX.RyujinxAg.xcscheme_^#shared#^_ orderHint - 1 + 2 SuppressBuildableAutocreation diff --git a/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift b/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift index 93236fe01..da759a4b4 100644 --- a/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift +++ b/src/MeloNX/MeloNX/App/Views/GamesList/GameListView.swift @@ -220,7 +220,7 @@ struct GameLibraryView: View { .onChange(of: searchText) { _ in isSearching = !searchText.isEmpty } - .fileImporter(isPresented: $isImporting, allowedContentTypes: [.zip, .folder]) { result in + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.zip, .folder, .nsp, .xci]) { result in switch result { case .success(let url): guard url.startAccessingSecurityScopedResource() else { @@ -278,35 +278,8 @@ struct GameLibraryView: View { print("File import failed: \(err.localizedDescription)") } } - .fileImporter(isPresented: $isSelectingGameUpdate, allowedContentTypes: [.nsp]) { result in - switch result { - case .success(let url): - guard let gameInfo, url.startAccessingSecurityScopedResource() else { - print("Failed to access security-scoped resource") - return - } - defer { url.stopAccessingSecurityScopedResource() } - - do { - let fileManager = FileManager.default - let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! - let romUpdatedDirectory = documentsDirectory.appendingPathComponent("updates") - - if !fileManager.fileExists(atPath: romUpdatedDirectory.path) { - try fileManager.createDirectory(at: romUpdatedDirectory, withIntermediateDirectories: true, attributes: nil) - } - - let destinationURL = romUpdatedDirectory.appendingPathComponent(url.lastPathComponent) - try? fileManager.copyItem(at: url, to: destinationURL) - - Ryujinx.shared.setTitleUpdate(titleId: gameInfo.titleId, updatePath: destinationURL.path) - Ryujinx.shared.games = Ryujinx.shared.loadGames() - } catch { - print("Error copying game file: \(error)") - } - case .failure(let err): - print("File import failed: \(err.localizedDescription)") - } + .sheet(isPresented: $isSelectingGameUpdate) { + UpdateManagerSheet(game: $gameInfo) } .sheet(isPresented: Binding( get: { isViewingGameInfo && gameInfo != nil }, @@ -523,7 +496,7 @@ struct GameListRow: View { gameInfo = game isSelectingGameUpdate.toggle() } label: { - Label("Add Game Update", systemImage: "chevron.up.circle") + Label("Game Update Manager", systemImage: "chevron.up.circle") } } diff --git a/src/MeloNX/MeloNX/App/Views/Updates/GameUpdateManagerSheet.swift b/src/MeloNX/MeloNX/App/Views/Updates/GameUpdateManagerSheet.swift new file mode 100644 index 000000000..da5209654 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Views/Updates/GameUpdateManagerSheet.swift @@ -0,0 +1,191 @@ +// +// GameUpdateManagerSheet.swift +// MeloNX +// +// Created by Stossy11 on 16/02/2025. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct UpdateManagerSheet: View { + @State private var items: [String] = [] + @State private var paths: [URL] = [] + @State private var selectedItem: String? = nil + @Binding var game: Game? + @State private var isSelectingGameUpdate = false + @State private var jsonURL: URL? = nil + + var body: some View { + NavigationView { + VStack { + List(paths, id: \..self) { item in + Button(action: { + selectItem(item.lastPathComponent) + }) { + HStack { + Text(item.lastPathComponent) + if selectedItem == "\(game!.titleId)/\(item.lastPathComponent)" { + Spacer() + Image(systemName: "checkmark") + } + } + } + .contextMenu { + Button { + removeUpdate(item) + } label: { + Text("Remove Update") + } + } + } + } + .onAppear() { + print(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json")) + + loadJSON(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json")) + } + .navigationTitle("\(game!.titleName) Updates") + .toolbar { + Button("+") { + isSelectingGameUpdate = true + } + } + } + .fileImporter(isPresented: $isSelectingGameUpdate, allowedContentTypes: [.item]) { result in + switch result { + case .success(let url): + guard url.startAccessingSecurityScopedResource() else { + print("Failed to access security-scoped resource") + return + } + defer { url.stopAccessingSecurityScopedResource() } + + let gameInfo = game! + + do { + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let updatedDirectory = documentsDirectory.appendingPathComponent("updates") + let romUpdatedDirectory = updatedDirectory.appendingPathComponent(gameInfo.titleId) + + if !fileManager.fileExists(atPath: updatedDirectory.path) { + try fileManager.createDirectory(at: updatedDirectory, withIntermediateDirectories: true, attributes: nil) + } + + if !fileManager.fileExists(atPath: romUpdatedDirectory.path) { + try fileManager.createDirectory(at: romUpdatedDirectory, withIntermediateDirectories: true, attributes: nil) + } + + let destinationURL = romUpdatedDirectory.appendingPathComponent(url.lastPathComponent) + try? fileManager.copyItem(at: url, to: destinationURL) + + Ryujinx.shared.setTitleUpdate(titleId: gameInfo.titleId, updatePath: "\(gameInfo.titleId)/" + url.lastPathComponent) + Ryujinx.shared.games = Ryujinx.shared.loadGames() + loadJSON(jsonURL!) + } catch { + print("Error copying game file: \(error)") + } + case .failure(let err): + print("File import failed: \(err.localizedDescription)") + } + } + } + + func removeUpdate(_ game: URL) { + let gameString = "\(self.game!.titleId)/\(game.lastPathComponent)" + paths.removeAll { $0 == game } + items.removeAll { $0 == gameString } + + if selectedItem == gameString { + selectedItem = nil + } + + do { + try FileManager.default.removeItem(at: game) + } catch { + print(error) + } + + saveJSON(selectedItem: selectedItem ?? "") + } + + func saveJSON(selectedItem: String) { + guard let jsonURL = jsonURL else { return } + do { + let jsonDict = ["paths": items, "selected": selectedItem] as [String: Any] + let newData = try JSONSerialization.data(withJSONObject: jsonDict, options: .prettyPrinted) + try newData.write(to: jsonURL) + } catch { + print("Failed to update JSON: \(error)") + } + } + + func loadJSON(_ json: URL) { + + self.jsonURL = json + print("Failed to read JSO") + + guard let jsonURL = jsonURL else { return } + print("Failed to read JSOK") + + do { + let data = try Data(contentsOf: jsonURL) + if let jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let list = jsonDict["paths"] as? [String] { + var urls: [URL] = [] + + for path in list { + urls.append(URL.documentsDirectory.appendingPathComponent("updates").appendingPathComponent(path)) + } + + self.items = list + self.paths = urls + self.selectedItem = jsonDict["selected"] as? String + } + } catch { + print("Failed to read JSON: \(error)") + createDefaultJSON() + } + } + + func createDefaultJSON() { + guard let jsonURL = jsonURL else { return } + let defaultData: [String: Any] = ["selected": "", "paths": []] + do { + let newData = try JSONSerialization.data(withJSONObject: defaultData, options: .prettyPrinted) + try newData.write(to: jsonURL) + self.items = [] + self.selectedItem = "" + } catch { + print("Failed to create default JSON: \(error)") + } + } + + func selectItem(_ item: String) { + let newSelection = "\(game!.titleId)/\(item)" + + guard let jsonURL = jsonURL else { return } + + do { + let data = try Data(contentsOf: jsonURL) + var jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] ?? [:] + + if let currentSelected = jsonDict["selected"] as? String, currentSelected == newSelection { + jsonDict["selected"] = "" + selectedItem = "" + } else { + jsonDict["selected"] = newSelection + selectedItem = newSelection + } + + jsonDict["paths"] = items + + let newData = try JSONSerialization.data(withJSONObject: jsonDict, options: .prettyPrinted) + try newData.write(to: jsonURL) + } catch { + print("Failed to update JSON: \(error)") + } + } + +} diff --git a/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs b/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs index 87141ab85..0c23c843c 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs @@ -93,6 +93,7 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions if (File.Exists(titleUpdateMetadataPath)) { string updatePath = PlatformRelative(JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected); + Logger.Info?.Print(LogClass.Application, $"Game Update Path: ${updatePath}."); if (File.Exists(updatePath)) { PartitionFileSystem updatePartitionFileSystem = new(); diff --git a/src/Ryujinx.Headless.SDL2/Program.cs b/src/Ryujinx.Headless.SDL2/Program.cs index 54c1aee82..a5f799fac 100644 --- a/src/Ryujinx.Headless.SDL2/Program.cs +++ b/src/Ryujinx.Headless.SDL2/Program.cs @@ -148,15 +148,29 @@ namespace Ryujinx.Headless.SDL2 var updatePath = Marshal.PtrToStringAnsi(updatePathPtr); string _updateJsonPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleId, "updates.json"); - TitleUpdateMetadata _titleUpdateWindowData = new TitleUpdateMetadata - { + TitleUpdateMetadata _titleUpdateWindowData; + + if (File.Exists(_updateJsonPath)) { + _titleUpdateWindowData = JsonHelper.DeserializeFromFile(_updateJsonPath, _titleSerializerContext.TitleUpdateMetadata); + + _titleUpdateWindowData.Paths ??= new List(); + if (!_titleUpdateWindowData.Paths.Contains(updatePath)) { + _titleUpdateWindowData.Paths.Add(updatePath); + } + + _titleUpdateWindowData.Selected = updatePath; + } else { + _titleUpdateWindowData = new TitleUpdateMetadata { Selected = updatePath, - Paths = new List(), + Paths = new List { updatePath }, }; + } + JsonHelper.SerializeToFile(_updateJsonPath, _titleUpdateWindowData, _titleSerializerContext.TitleUpdateMetadata); } + [UnmanagedCallersOnly(EntryPoint = "get_current_fps")] public static unsafe int GetFPS() {