diff --git a/src/ios/AdvancedSettingsView.swift b/src/ios/AdvancedSettingsView.swift new file mode 100644 index 0000000000..9c40af442c --- /dev/null +++ b/src/ios/AdvancedSettingsView.swift @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UniformTypeIdentifiers + +struct AdvancedSettingsView: View { + @AppStorage("exitgame") var exitgame: Bool = false + @AppStorage("ClearBackingRegion") var kpagetable: Bool = false + @AppStorage("WaitingforJIT") var waitingJIT: Bool = false + @AppStorage("cangetfullpath") var canGetFullPath: Bool = false + @AppStorage("onscreenhandheld") var onscreenjoy: Bool = false + var body: some View { + ScrollView { + Rectangle() + .fill(Color(uiColor: UIColor.secondarySystemBackground)) + .cornerRadius(10) + .frame(width: .infinity, height: 50) + .overlay() { + HStack { + Toggle("Exit Game Button", isOn: $exitgame) + .padding() + } + } + Text("This is very unstable and can lead to game freezing and overall bad preformance after you exit a game") + .padding(.bottom) + .font(.footnote) + .foregroundColor(.gray) + Rectangle() + .fill(Color(uiColor: UIColor.secondarySystemBackground)) + .cornerRadius(10) + .frame(width: .infinity, height: 50) + .overlay() { + HStack { + Toggle("Memory Usage Increase", isOn: $kpagetable) + .padding() + } + } + Text("This makes games way more stable but a lot of games will crash as you will run out of Memory way quicker. (Don't Enable this on devices with less then 8GB of memory as most games will crash)") + .padding(.bottom) + .font(.footnote) + .foregroundColor(.gray) + + Rectangle() + .fill(Color(uiColor: UIColor.secondarySystemBackground)) + .cornerRadius(10) + .frame(width: .infinity, height: 50) + .overlay() { + HStack { + Toggle("Check for Booting OS", isOn: $canGetFullPath) + .padding() + } + } + Text("If you do not have the neccesary files for Booting the Switch OS, it will just crash almost instantly.") + .padding(.bottom) + .font(.footnote) + .foregroundColor(.gray) + + Rectangle() + .fill(Color(uiColor: UIColor.secondarySystemBackground)) + .cornerRadius(10) + .frame(width: .infinity, height: 50) + .overlay() { + HStack { + Toggle("Set OnScreen Controls to Handheld", isOn: $onscreenjoy) + .padding() + } + } + Text("You need in Core Settings to set \"use_docked_mode = 0\"") + .padding(.bottom) + .font(.footnote) + .foregroundColor(.gray) + } + } +} diff --git a/src/ios/Air.swift b/src/ios/Air.swift new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/ios/AirPlay.swift b/src/ios/AirPlay.swift new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/ios/AppIconProvider.swift b/src/ios/AppIconProvider.swift new file mode 100644 index 0000000000..61ebd4df64 --- /dev/null +++ b/src/ios/AppIconProvider.swift @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +enum AppIconProvider { + static func appIcon(in bundle: Bundle = .main) -> String { + guard let icons = bundle.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], + let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], + let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], + let iconFileName = iconFiles.last else { + print("Could not find icons in bundle") + return "" + } + return iconFileName + } +} diff --git a/src/ios/AppUI.swift b/src/ios/AppUI.swift index b1d95324cc..4aff2db905 100644 --- a/src/ios/AppUI.swift +++ b/src/ios/AppUI.swift @@ -8,9 +8,7 @@ import Foundation import QuartzCore.CAMetalLayer public struct AppUI { - public static let shared = AppUI() - fileprivate let appUIObjC = AppUIObjC.shared() public func configure(layer: CAMetalLayer, with size: CGSize) { diff --git a/src/ios/BootOSView.swift b/src/ios/BootOSView.swift new file mode 100644 index 0000000000..5af5d55145 --- /dev/null +++ b/src/ios/BootOSView.swift @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import AppUI + +struct BootOSView: View { + @Binding var core: Core + @Binding var currentnavigarion: Int + @State var appui = AppUI.shared + @AppStorage("cangetfullpath") var canGetFullPath: Bool = false + var body: some View { + if (appui.canGetFullPath() -- canGetFullPath) { + EmulationView(game: nil) + } else { + VStack { + Text("Unable Launch Switch OS") + .font(.largeTitle) + .padding() + Text("You do not have the Switch Home Menu Files Needed to launch the Ηome Menu") + } + } + } +} diff --git a/src/ios/CMakeLists.txt b/src/ios/CMakeLists.txt index 98b2585a5b..5d979c2ce5 100644 --- a/src/ios/CMakeLists.txt +++ b/src/ios/CMakeLists.txt @@ -17,8 +17,33 @@ add_executable(eden-ios EmulationWindow.mm VMA.cpp - PomeloApp.swift + EnableJIT.swift + EmulationGame.swift + JoystickView.swift + CoreSettingsView.swift ContentView.swift + EmulationHandler.swift + DetectServer.swift + NavView.swift + PomeloApp.swift + SettingsView.swift + FileManager.swift + EmulationView.swift + LibraryView.swift + GameButtonListView.swift + KeyboardHostingController.swift + MetalView.swift + BootOSView.swift + ControllerView.swift + AppUI.swift + InfoView.swift + FolderMonitor.swift + AdvancedSettingsView.swift + GameButtonView.swift + AppIconProvider.swift + Haptics.swift + EmulationScreenView.swift + GameListView.swift ) set(MACOSX_BUNDLE_GUI_IDENTIFIER "dev.eden-emu.eden") diff --git a/src/ios/ContentView.swift b/src/ios/ContentView.swift index 877af33fcb..07d41f89e6 100644 --- a/src/ios/ContentView.swift +++ b/src/ios/ContentView.swift @@ -4,16 +4,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI -//import AppUI +import AppUI struct ContentView: View { -// @State var core = Core(games: [], root: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]) +@State var core = Core(games: [], root: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]) var body: some View { -// HomeView(core: core).onAppear() { -// Air.play(AnyView( -// Text("Select Game").font(.system(size: 100)) -// )) -// // rest of death -// } + HomeView(core: core).onAppear() { + } } } diff --git a/src/ios/ControllerView.swift b/src/ios/ControllerView.swift new file mode 100644 index 0000000000..a19cdffd28 --- /dev/null +++ b/src/ios/ControllerView.swift @@ -0,0 +1,420 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import GameController +import AppUI +import SwiftUIJoystick + +struct ControllerView: View { + let appui = AppUI.shared + @State var isPressed = false + @State var controllerconnected = false + @State private var x: CGFloat = 0.0 + @State private var y: CGFloat = 0.0 + @Environment(\.presentationMode) var presentationMode + + var body: some View { + GeometryReader { geometry in + ZStack { + if !controllerconnected { + OnScreenController(geometry: geometry) // i did this to clean it up as it was quite long lmfao + } + } + } + .onAppear { + print("checking for controller:") + controllerconnected = false + DispatchQueue.main.async { + setupControllers() // i dont know what half of this shit does + } + } + } + + // Add a dictionary to track controller IDs + @State var controllerIDs: [GCController: Int] = [:] + + private func setupControllers() { + NotificationCenter.default.addObserver(forName: .GCControllerDidConnect, object: nil, queue: .main) { notification in + if let controller = notification.object as? GCController { + print("wow controller onstart") // yippeeee + self.setupController(controller) + self.controllerconnected = true + } else { + print("not GCController :((((((") // wahhhhhhh + } + } + + + NotificationCenter.default.addObserver(forName: .GCControllerDidDisconnect, object: nil, queue: .main) { notification in + if let controller = notification.object as? GCController { + print("wow controller gone") + if self.controllerIDs.isEmpty { + controllerconnected = false + } + self.controllerIDs.removeValue(forKey: controller) // Remove the controller ID + } + } + + GCController.controllers().forEach { controller in + print("wow controller") + self.controllerconnected = true + self.setupController(controller) + } + } + + private func setupController(_ controller: GCController) { + // Assign a unique ID to the controller, max 5 controllers + if controllerIDs.count < 6, controllerIDs[controller] == nil { + controllerIDs[controller] = controllerIDs.count + } + + guard let controllerId = controllerIDs[controller] else { return } + + if let extendedGamepad = controller.extendedGamepad { + + // Handle extended gamepad + extendedGamepad.dpad.up.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadUp, controllerId: controllerId) : self.touchUpInside(.directionalPadUp, controllerId: controllerId) + } + + extendedGamepad.dpad.down.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadDown, controllerId: controllerId) : self.touchUpInside(.directionalPadDown, controllerId: controllerId) + } + extendedGamepad.dpad.left.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadLeft, controllerId: controllerId) : self.touchUpInside(.directionalPadLeft, controllerId: controllerId) + } + extendedGamepad.dpad.right.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadRight, controllerId: controllerId) : self.touchUpInside(.directionalPadRight, controllerId: controllerId) + } + extendedGamepad.buttonOptions?.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.minus, controllerId: controllerId) : self.touchUpInside(.minus, controllerId: controllerId) + } + extendedGamepad.buttonMenu.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.plus, controllerId: controllerId) : self.touchUpInside(.plus, controllerId: controllerId) + } + extendedGamepad.buttonA.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.A, controllerId: controllerId) : self.touchUpInside(.A, controllerId: controllerId) + } + extendedGamepad.buttonB.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.B, controllerId: controllerId) : self.touchUpInside(.B, controllerId: controllerId) + } + extendedGamepad.buttonX.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.X, controllerId: controllerId) : self.touchUpInside(.X, controllerId: controllerId) + } + extendedGamepad.buttonY.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.Y, controllerId: controllerId) : self.touchUpInside(.Y, controllerId: controllerId) + } + extendedGamepad.leftShoulder.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.triggerL, controllerId: controllerId) : self.touchUpInside(.L, controllerId: controllerId) + } + extendedGamepad.leftTrigger.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.triggerZL, controllerId: controllerId) : self.touchUpInside(.triggerZL, controllerId: controllerId) + } + extendedGamepad.rightShoulder.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.triggerR, controllerId: controllerId) : self.touchUpInside(.triggerR, controllerId: controllerId) + } + extendedGamepad.leftThumbstickButton?.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.L, controllerId: controllerId) : self.touchUpInside(.triggerR, controllerId: controllerId) + } + extendedGamepad.rightThumbstickButton?.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.R, controllerId: controllerId) : self.touchUpInside(.triggerR, controllerId: controllerId) + } + extendedGamepad.rightTrigger.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.triggerZR, controllerId: controllerId) : self.touchUpInside(.triggerZR, controllerId: controllerId) + } + extendedGamepad.buttonHome?.pressedChangedHandler = { button, value, pressed in + if pressed { + appui.exit() + presentationMode.wrappedValue.dismiss() + } + } + extendedGamepad.leftThumbstick.valueChangedHandler = { dpad, x, y in + self.appui.thumbstickMoved(analog: .left, x: x, y: y, controllerid: controllerId) + } + + extendedGamepad.rightThumbstick.valueChangedHandler = { dpad, x, y in + self.appui.thumbstickMoved(analog: .right, x: x, y: y, controllerid: controllerId) + } + + if let motion = controller.motion { + var lastTimestamp = Date().timeIntervalSince1970 // Initialize timestamp when motion starts + + motion.valueChangedHandler = { motion in + // Get current time + let currentTimestamp = Date().timeIntervalSince1970 + let deltaTimestamp = Int32((currentTimestamp - lastTimestamp) * 1000) // Difference in milliseconds + + // Update last timestamp + lastTimestamp = currentTimestamp + + // Get gyroscope data + let gyroX = motion.rotationRate.x + let gyroY = motion.rotationRate.y + let gyroZ = motion.rotationRate.z + + // Get accelerometer data + let accelX = motion.gravity.x + motion.userAcceleration.x + let accelY = motion.gravity.y + motion.userAcceleration.y + let accelZ = motion.gravity.z + motion.userAcceleration.z + + print("\(gyroX), \(gyroY), \(gyroZ), \(accelX), \(accelY), \(accelZ)") + + // Call your gyroMoved function with the motion data + appui.gyroMoved(x: Float(gyroX), y: Float(gyroY), z: Float(gyroZ), accelX: Float(accelX), accelY: Float(accelY), accelZ: Float(accelZ), controllerId: Int32(controllerId), deltaTimestamp: Int32(lastTimestamp)) + } + } + } else if let microGamepad = controller.microGamepad { + // Handle micro gamepad + microGamepad.dpad.up.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadUp, controllerId: controllerId) : self.touchUpInside(.directionalPadUp, controllerId: controllerId) + } + microGamepad.dpad.down.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadDown, controllerId: controllerId) : self.touchUpInside(.directionalPadDown, controllerId: controllerId) + } + microGamepad.dpad.left.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadLeft, controllerId: controllerId) : self.touchUpInside(.directionalPadLeft, controllerId: controllerId) + } + microGamepad.dpad.right.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.directionalPadRight, controllerId: controllerId) : self.touchUpInside(.directionalPadRight, controllerId: controllerId) + } + microGamepad.buttonA.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.A, controllerId: controllerId) : self.touchUpInside(.A, controllerId: controllerId) + } + microGamepad.buttonX.pressedChangedHandler = { button, value, pressed in + pressed ? self.touchDown(.X, controllerId: controllerId) : self.touchUpInside(.X, controllerId: controllerId) + } + } + } + + private func touchDown(_ button: VirtualControllerButtonType, controllerId: Int) { + appui.virtualControllerButtonDown(button: button, controllerid: controllerId) } + + private func touchUpInside(_ button: VirtualControllerButtonType, controllerId: Int) { + appui.virtualControllerButtonUp(button: button, controllerid: controllerId) + } +} + +struct OnScreenController: View { + @State var geometry: GeometryProxy + var body: some View { + if geometry.size.height > geometry.size.width && UIDevice.current.userInterfaceIdiom != .pad { + // portrait + VStack { + Spacer() + VStack { + HStack { + VStack { + ShoulderButtonsViewLeft() + ZStack { + Joystick() + DPadView() + } + } + .padding() + VStack { + ShoulderButtonsViewRight() + ZStack { + Joystick(iscool: true) // hope this works + ABXYView() + } + } + .padding() + } + HStack { + ButtonView(button: .plus).padding(.horizontal, 40) + ButtonView(button: .minus).padding(.horizontal, 40) + } + } + .padding(.bottom, geometry.size.height / 3.2) // very broken + } + } else { + // could be landscape + VStack { + HStack { + Spacer() + ButtonView(button: .home) + .padding(.horizontal) + } + Spacer() + VStack { + HStack { + + // gotta fuckin add + and - now + VStack { + ShoulderButtonsViewLeft() + ZStack { + Joystick() + DPadView() + } + } + HStack { + Spacer() + VStack { + Spacer() + ButtonView(button: .plus) // Adding the + button + } + VStack { + Spacer() + ButtonView(button: .minus) // Adding the - button + } + Spacer() + } + VStack { + ShoulderButtonsViewRight() + ZStack { + Joystick(iscool: true) // hope this work s + ABXYView() + } + } + } + + } + .padding(.bottom, geometry.size.height / 11) // also extremally broken ( + } + } + } +} + +struct ShoulderButtonsViewLeft: View { + var body: some View { + HStack { + ButtonView(button: .triggerZL) + .padding(.horizontal) + ButtonView(button: .triggerL) + .padding(.horizontal) + } + .frame(width: 160, height: 20) + } +} + +struct ShoulderButtonsViewRight: View { + var body: some View { + HStack { + ButtonView(button: .triggerR) + .padding(.horizontal) + ButtonView(button: .triggerZR) + .padding(.horizontal) + } + .frame(width: 160, height: 20) + + } +} + +struct DPadView: View { + var body: some View { + VStack { + ButtonView(button: .directionalPadUp) + HStack { + ButtonView(button: .directionalPadLeft) + Spacer(minLength: 20) + ButtonView(button: .directionalPadRight) + } + ButtonView(button: .directionalPadDown) + .padding(.horizontal) + } + .frame(width: 145, height: 145) + } +} + +struct ABXYView: View { + var body: some View { + VStack { + ButtonView(button: .X) + HStack { + ButtonView(button: .Y) + Spacer(minLength: 20) + ButtonView(button: .A) + } + ButtonView(button: .B) + .padding(.horizontal) + } + .frame(width: 145, height: 145) + } +} + +struct ButtonView: View { + var button: VirtualControllerButtonType + @StateObject private var viewModel: EmulationViewModel = EmulationViewModel(game: nil) + let appui = AppUI.shared + @State var mtkView: MTKView? + @State var width: CGFloat = 45 + @State var height: CGFloat = 45 + @State var isPressed = false + var id: Int { + if onscreenjoy { + return 8 + } + return 0 + } + @AppStorage("onscreenhandheld") var onscreenjoy: Bool = false + @Environment(\.colorScheme) var colorScheme + @Environment(\.presentationMode) var presentationMode + + var body: some View { + Image(systemName: buttonText) + .resizable() + .frame(width: width, height: height) + .foregroundColor(colorScheme == .dark ? Color.gray : Color.gray) + .opacity(isPressed ? 0.5 : 1) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !self.isPressed { + self.isPressed = true + DispatchQueue.main.async { + if button == .home { + presentationMode.wrappedValue.dismiss() + appui.exit() + } else { + appui.virtualControllerButtonDown(button: button, controllerid: id) + Haptics.shared.play(.heavy) + } + } + } + } + .onEnded { _ in + self.isPressed = false + DispatchQueue.main.async { + if button != .home { + appui.virtualControllerButtonUp(button: button, controllerid: id) + } + } + } + ) + .onAppear() { + if button == .triggerL || button == .triggerZL || button == .triggerZR || button == .triggerR { + width = 65 + } + + + if button == .minus || button == .plus || button == .home { + width = 35 + height = 35 + } + } + } + + private var buttonText: String { + switch button { + case .A: return "a.circle.fill" + case .B: return "b.circle.fill" + case .X: return "x.circle.fill" + case .Y: return "y.circle.fill" + case .directionalPadUp: return "arrowtriangle.up.circle.fill" + case .directionalPadDown: return "arrowtriangle.down.circle.fill" + case .directionalPadLeft: return "arrowtriangle.left.circle.fill" + case .directionalPadRight: return "arrowtriangle.right.circle.fill" + case .triggerZL: return"zl.rectangle.roundedtop.fill" + case .triggerZR: return "zr.rectangle.roundedtop.fill" + case .triggerL: return "l.rectangle.roundedbottom.fill" + case .triggerR: return "r.rectangle.roundedbottom.fill" + case .plus: return "plus.circle.fill" + case .minus: return "minus.circle.fill" + case .home: return "house.circle.fill" + default: return "" + } + } +} diff --git a/src/ios/CoreSettingsView.swift b/src/ios/CoreSettingsView.swift new file mode 100644 index 0000000000..12b5c3c624 --- /dev/null +++ b/src/ios/CoreSettingsView.swift @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import Foundation +import AppUI + +struct CoreSettingsView: View { + @State private var text: String = "" + @State private var isLoading: Bool = true + @Environment(\.presentationMode) var presentationMode + + var body: some View { + VStack { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + TextEditor(text: $text) + .padding() + + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let configfolder = documentDirectory.appendingPathComponent("config", conformingTo: .folder) + let fileURL = configfolder.appendingPathComponent("config.ini") + + presentationMode.wrappedValue.dismiss() + + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + print("\(error.localizedDescription)") + } + + AppUI.shared.settingsSaved() + + } label: { + Text("Reset File") + } + } + } + .onAppear { + loadFile() + } + .onDisappear() { + saveFile() + } + } + + private func loadFile() { + let fileManager = FileManager.default + let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let configfolder = documentDirectory.appendingPathComponent("config", conformingTo: .folder) + let fileURL = configfolder.appendingPathComponent("config.ini") + + if fileManager.fileExists(atPath: fileURL.path) { + do { + text = try String(contentsOf: fileURL, encoding: .utf8) + } catch { + print("Error reading file: \(error)") + } + } else { + text = "" // Initialize with empty text if file doesn't exist + } + isLoading = false + } + + private func saveFile() { + let fileManager = FileManager.default + let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let configfolder = documentDirectory.appendingPathComponent("config", conformingTo: .folder) + let fileURL = configfolder.appendingPathComponent("config.ini") + + do { + try text.write(to: fileURL, atomically: true, encoding: .utf8) + AppUI.shared.settingsSaved() + print("File saved successfully!") + } catch { + print("Error saving file: \(error)") + } + } +} diff --git a/src/ios/DetectServer.swift b/src/ios/DetectServer.swift new file mode 100644 index 0000000000..a36563fc66 --- /dev/null +++ b/src/ios/DetectServer.swift @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +func isSideJITServerDetected(completion: @escaping (Result) -> Void) { + let address = UserDefaults.standard.string(forKey: "sidejitserver") ?? "" + var SJSURL = address + if (address).isEmpty { + SJSURL = "http://sidejitserver._http._tcp.local:8080" + } + // Create a network operation at launch to Refresh SideJITServer + let url = URL(string: SJSURL)! + let task = URLSession.shared.dataTask(with: url) { (data, response, error) in + if let error = error { + print("No SideJITServer on Network") + completion(.failure(error)) + return + } + completion(.success(())) + } + task.resume() + return +} diff --git a/src/ios/EmulationGame.swift b/src/ios/EmulationGame.swift new file mode 100644 index 0000000000..da06753279 --- /dev/null +++ b/src/ios/EmulationGame.swift @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +struct EmulationGame : Comparable, Hashable, Identifiable { + var id = UUID() + + let developer: String + let fileURL: URL + let imageData: Data + let title: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(developer) + hasher.combine(fileURL) + hasher.combine(imageData) + hasher.combine(title) + } + + static func < (lhs: EmulationGame, rhs: Yuzu) -> Bool { + lhs.title < rhs.title + } + + static func == (lhs: EmulationGame, rhs: Yuzu) -> Bool { + lhs.title == rhs.title + } +} diff --git a/src/ios/EmulationHandler.swift b/src/ios/EmulationHandler.swift new file mode 100644 index 0000000000..9ae1e2f248 --- /dev/null +++ b/src/ios/EmulationHandler.swift @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import AppUI +import Metal +import Foundation + +class EmulationViewModel: ObservableObject { + @Published var isShowingCustomButton = true + @State var should = false + var device: MTLDevice? + @State var mtkView: MTKView = MTKView() + var CaLayer: CAMetalLayer? + private var sudachiGame: EmulationGame? + private let appui = AppUI.shared + private var thread: Thread! + private var isRunning = false + var doesneedresources = false + @State var iscustom: Bool = false + + init(game: EmulationGame?) { + self.device = MTLCreateSystemDefaultDevice() + self.sudachiGame = game + } + + func configureAppUI(with mtkView: MTKView) { + self.mtkView = mtkView + device = self.mtkView.device + guard !isRunning else { return } + isRunning = true + appui.configure(layer: mtkView.layer as! CAMetalLayer, with: mtkView.frame.size) + + iscustom = ((sudachiGame?.fileURL.startAccessingSecurityScopedResource()) != nil) + + DispatchQueue.global(qos: .userInitiated).async { [self] in + if let sudachiGame = self.sudachiGame { + self.appui.insert(game: sudachiGame.fileURL) + } else { + self.appui.bootOS() + } + } + + thread = .init(block: self.step) + thread.name = "Yuzu" + thread.qualityOfService = .userInteractive + thread.threadPriority = 0.9 + thread.start() + } + + private func step() { + while true { + appui.step() + } + } + + func customButtonTapped() { + stopEmulation() + } + + private func stopEmulation() { + if isRunning { + isRunning = false + appui.exit() + thread.cancel() + if iscustom { + sudachiGame?.fileURL.stopAccessingSecurityScopedResource() + } + } + } + + func handleOrientationChange() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let interfaceOrientation = self.getInterfaceOrientation(from: UIDevice.current.orientation) + self.appui.orientationChanged(orientation: interfaceOrientation, with: self.mtkView.layer as! CAMetalLayer, size: mtkView.frame.size) + } + } + + private func getInterfaceOrientation(from deviceOrientation: UIDeviceOrientation) -> UIInterfaceOrientation { + switch deviceOrientation { + case .portrait: + return .portrait + case .portraitUpsideDown: + return .portraitUpsideDown + case .landscapeLeft: + return .landscapeRight + case .landscapeRight: + return .landscapeLeft + default: + return .unknown + } + } +} diff --git a/src/ios/EmulationScreenView.swift b/src/ios/EmulationScreenView.swift new file mode 100644 index 0000000000..9db9d5abba --- /dev/null +++ b/src/ios/EmulationScreenView.swift @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import AppUI +import MetalKit + +class EmulationScreenView: UIView { + var primaryScreen: UIView! + var portraitconstraints = [NSLayoutConstraint]() + var landscapeconstraints = [NSLayoutConstraint]() + var fullscreenconstraints = [NSLayoutConstraint]() + let appui = AppUI.shared + let userDefaults = UserDefaults.standard + + override init(frame: CGRect) { + super.init(frame: frame) + if UIDevice.current.userInterfaceIdiom == .pad { + setupAppUIScreenforiPad() + } else { + setupAppUIScreen() + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + if UIDevice.current.userInterfaceIdiom == .pad { + setupAppUIScreenforiPad() + } else { + setupAppUIScreen() + } + + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + guard let touch = touches.first else { + return + } + + print("Location: \(touch.location(in: primaryScreen))") + appui.touchBegan(at: touch.location(in: primaryScreen), for: 0) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + print("Touch Ended") + appui.touchEnded(for: 0) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + guard let touch = touches.first else { + return + } + let location = touch.location(in: primaryScreen) + print("Location Moved: \(location)") + appui.touchMoved(at: location, for: 0) + } + + func setupAppUIScreenforiPad() { + primaryScreen = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) + primaryScreen.translatesAutoresizingMaskIntoConstraints = false + primaryScreen.clipsToBounds = true + primaryScreen.layer.borderColor = UIColor.secondarySystemBackground.cgColor + primaryScreen.layer.borderWidth = 3 + primaryScreen.layer.cornerCurve = .continuous + primaryScreen.layer.cornerRadius = 10 + addSubview(primaryScreen) + + + portraitconstraints = [ + primaryScreen.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), + primaryScreen.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 10), + primaryScreen.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -10), + primaryScreen.heightAnchor.constraint(equalTo: primaryScreen.widthAnchor, multiplier: 9 / 16), + ] + + landscapeconstraints = [ + primaryScreen.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 50), + primaryScreen.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -100), + primaryScreen.widthAnchor.constraint(equalTo: primaryScreen.heightAnchor, multiplier: 16 / 9), + primaryScreen.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + ] + + + updateConstraintsForOrientation() + } + + + + func setupAppUIScreen() { + primaryScreen = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) + primaryScreen.translatesAutoresizingMaskIntoConstraints = false + primaryScreen.clipsToBounds = true + primaryScreen.layer.borderColor = UIColor.secondarySystemBackground.cgColor + primaryScreen.layer.borderWidth = 3 + primaryScreen.layer.cornerCurve = .continuous + primaryScreen.layer.cornerRadius = 10 + addSubview(primaryScreen) + + + portraitconstraints = [ + primaryScreen.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), + primaryScreen.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 10), + primaryScreen.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -10), + primaryScreen.heightAnchor.constraint(equalTo: primaryScreen.widthAnchor, multiplier: 9 / 16), + ] + + landscapeconstraints = [ + primaryScreen.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), + primaryScreen.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -10), + primaryScreen.widthAnchor.constraint(equalTo: primaryScreen.heightAnchor, multiplier: 16 / 9), + primaryScreen.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + ] + + updateConstraintsForOrientation() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateConstraintsForOrientation() + } + + private func updateConstraintsForOrientation() { + removeConstraints(portraitconstraints) + removeConstraints(landscapeconstraints) + let isPortrait = UIApplication.shared.statusBarOrientation.isPortrait + addConstraints(isPortrait ? portraitconstraints : landscapeconstraints) + } +} diff --git a/src/ios/EmulationView.swift b/src/ios/EmulationView.swift new file mode 100644 index 0000000000..586afa88c1 --- /dev/null +++ b/src/ios/EmulationView.swift @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import AppUI +import Foundation +import GameController +import UIKit +import SwiftUIIntrospect + +struct EmulationView: View { + @StateObject private var viewModel: EmulationViewModel + @State var controllerconnected = false + @State var appui = AppUI.shared + var device: MTLDevice? = MTLCreateSystemDefaultDevice() + @State var CaLayer: CAMetalLayer? + @State var ShowPopup: Bool = false + @State var mtkview: MTKView? + @State private var thread: Thread! + @State var uiTabBarController: UITabBarController? + @State private var isFirstFrameShown = false + @State private var timer: Timer? + @Environment(\.scenePhase) var scenePhase + + init(game: EmulationGame?) { + _viewModel = StateObject(wrappedValue: EmulationViewModel(game: game)) + } + + var body: some View { + ZStack { + MetalView(device: device) { view in + DispatchQueue.main.async { + if let metalView = view as? MTKView { + mtkview = metalView + viewModel.configureAppUI(with: metalView) + } else { + print("Error: view is not of type MTKView") + } + } + } + .onRotate { size in + viewModel.handleOrientationChange() + } + ControllerView() + } + .overlay( + // Loading screen overlay on top of MetalView + Group { + if !isFirstFrameShown { + LoadingView() + } + } + .transition(.opacity) + ) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + startPollingFirstFrameShowed() + } + .onDisappear { + stopPollingFirstFrameShowed() + uiTabBarController?.tabBar.isHidden = false + viewModel.customButtonTapped() + } + .navigationBarBackButtonHidden(true) + .introspect(.tabView, on: .iOS(.v13, .v14, .v15, .v16, .v17)) { (tabBarController) in + tabBarController.tabBar.isHidden = true + uiTabBarController = tabBarController + } + } + + private func startPollingFirstFrameShowed() { + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + if appui.FirstFrameShowed() { + withAnimation { + isFirstFrameShown = true + } + stopPollingFirstFrameShowed() + } + } + } + + private func stopPollingFirstFrameShowed() { + timer?.invalidate() + timer = nil + print("Timer Invalidated") + } +} + +struct LoadingView: View { + var body: some View { + VStack { + ProgressView("Loading...") + // .font(.system(size: 90)) + .progressViewStyle(CircularProgressViewStyle()) + .padding() + Text("Please wait while the game loads.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.8)) + .foregroundColor(.white) + } +} + +extension View { + func onRotate(perform action: @escaping (CGSize) -> Void) -> some View { + self.modifier(DeviceRotationModifier(action: action)) + } +} + +struct DeviceRotationModifier: ViewModifier { + let action: (CGSize) -> Void + @State var startedfirst: Bool = false + + func body(content: Content) -> some View { content + .background(GeometryReader { geometry in + Color.clear + .preference(key: SizePreferenceKey.self, value: geometry.size) + }) + .onPreferenceChange(SizePreferenceKey.self) { newSize in + if startedfirst { + action(newSize) + } else { + startedfirst = true + } + } + } +} + +struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} diff --git a/src/ios/EnableJIT.swift b/src/ios/EnableJIT.swift new file mode 100644 index 0000000000..105615fd29 --- /dev/null +++ b/src/ios/EnableJIT.swift @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +enum SideJITServerErrorType: Error { + case invalidURL + case errorConnecting + case deviceNotFound + case other(String) +} + +func sendrequestsidejit(url: String, completion: @escaping (Result) -> Void) { + let url = URL(string: url)! + let task = URLSession.shared.dataTask(with: url) {(data, response, error) in + if let error = error { + completion(.failure(.errorConnecting)) + return + } + guard let data = data, let datastring = String(data: data, encoding: .utf8) else { return } + if datastring == "Enabled JIT" { + completion(.success(())) + } else { + let errorType: SideJITServerErrorType = datastring == "Could not find device!" ? .deviceNotFound : .other(datastring) + completion(.failure(errorType)) + } + } + task.resume() +} + +func sendrefresh(url: String, completion: @escaping (Result) -> Void) { + let url = URL(string: url)! + + let task = URLSession.shared.dataTask(with: url) {(data, response, error) in + if let error = error { + completion(.failure(.errorConnecting)) + return + } + + guard let data = data, let datastring = String(data: data, encoding: .utf8) else { return } + let inputText = "{\"OK\":\"Refreshed!\"}" + if datastring == inputText { + completion(.success(())) + } else { + let errorType: SideJITServerErrorType = datastring == "Could not find device!" ? .deviceNotFound : .other(datastring) + completion(.failure(errorType)) + } + } + task.resume() +} diff --git a/src/ios/FileManager.swift b/src/ios/FileManager.swift new file mode 100644 index 0000000000..f3fddff8b1 --- /dev/null +++ b/src/ios/FileManager.swift @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import Foundation +import UIKit +import AppUI +import Zip + +struct Core : Comparable, Hashable { + + let name = "Yuzu" + var games: [EmulationGame] + let root: URL + + static func < (lhs: Core, rhs: Core) -> Bool { + lhs.name < rhs.name + } + + func AddFirmware(at fileURL: URL) { + do { + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let destinationURL = documentsDirectory.appendingPathComponent("nand/system/Contents/registered") + + + if !fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil) + } + + + try Zip.unzipFile(fileURL, destination: destinationURL, overwrite: true, password: nil) + print("File unzipped successfully to \(destinationURL.path)") + + } catch { + print("Failed to unzip file: \(error)") + } + } +} + +class YuzuFileManager { + static var shared = YuzuFileManager() + + func directories() -> [String : [String : String]] { + [ + "themes" : [:], + "amiibo" : [:], + "cache" : [:], + "config" : [:], + "crash_dumps" : [:], + "dump" : [:], + "keys" : [:], + "load" : [:], + "log" : [:], + "nand" : [:], + "play_time" : [:], + "roms" : [:], + "screenshots" : [:], + "sdmc" : [:], + "shader" : [:], + "tas" : [:], + "icons" : [:] + ] + } + + func createdirectories() throws { + let documentdir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + try directories().forEach() { directory, filename in + let directoryURL = documentdir.appendingPathComponent(directory) + + if !FileManager.default.fileExists(atPath: directoryURL.path) { + print("creating dir at \(directoryURL.path)") // yippee + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: false, attributes: nil) + } + } + } + + func DetectKeys() -> (Bool, Bool) { + var prodkeys = false + var titlekeys = false + let filemanager = FileManager.default + let documentdir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let KeysFolderURL = documentdir.appendingPathComponent("keys") + prodkeys = filemanager.fileExists(atPath: KeysFolderURL.appendingPathComponent("prod.keys").path) + titlekeys = filemanager.fileExists(atPath: KeysFolderURL.appendingPathComponent("title.keys").path) + return (prodkeys, titlekeys) + } +} + +enum LibManError : Error { + case ripenum, urlgobyebye +} + +class LibraryManager { + static let shared = LibraryManager() + let documentdir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("roms", conformingTo: .folder) + + + func removerom(_ game: EmulationGame) throws { + do { + try FileManager.default.removeItem(at: game.fileURL) + } catch { + throw error + } + } + + func homebrewroms() -> [EmulationGame] { + // TODO(lizzie): this is horrible + var urls: [URL] = [] + let sdmc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sdmc", conformingTo: .folder) + let sdfolder = sdmc.appendingPathComponent("switch", conformingTo: .folder) + if FileManager.default.fileExists(atPath: sdfolder.path) { + if let dirContents = FileManager.default.enumerator(at: sdmc, includingPropertiesForKeys: nil, options: []) { + do { + try dirContents.forEach() { files in + if let file = files as? URL { + let getaboutfile = try file.resourceValues(forKeys: [.isRegularFileKey]) + if let isfile = getaboutfile.isRegularFile, isfile { + if ["nso", "nro"].contains(file.pathExtension.lowercased()) { + urls.append(file) + } + } + } + } + } catch { + if let dirContents = FileManager.default.enumerator(at: documentdir, includingPropertiesForKeys: nil, options: []) { + do { + try dirContents.forEach() { files in + if let file = files as? URL { + let getaboutfile = try file.resourceValues(forKeys: [.isRegularFileKey]) + if let isfile = getaboutfile.isRegularFile, isfile { + if ["nso", "nro"].contains(file.pathExtension.lowercased()) { + urls.append(file) + } + } + } + } + } catch { + print("damn") + if let dirContents = FileManager.default.enumerator(at: documentdir, includingPropertiesForKeys: nil, options: []) { + do { + try dirContents.forEach() { files in + if let file = files as? URL { + let getaboutfile = try file.resourceValues(forKeys: [.isRegularFileKey]) + if let isfile = getaboutfile.isRegularFile, isfile { + if ["nso", "nro"].contains(file.pathExtension.lowercased()) { + urls.append(file) + } + } + } + } + } catch { + return [] + } + } else { + return [] + } + + } + } + } + } + } + if let dirContents = FileManager.default.enumerator(at: documentdir, includingPropertiesForKeys: nil, options: []) { + do { + try dirContents.forEach() { files in + if let file = files as? URL { + let getaboutfile = try file.resourceValues(forKeys: [.isRegularFileKey]) + if let isfile = getaboutfile.isRegularFile, isfile { + if ["nso", "nro"].contains(file.pathExtension.lowercased()) { + urls.append(file) + } + } + } + } + } catch { + return [] + } + } else { + return [] + } + func games(from urls: [URL]) -> [EmulationGame] { + var pomelogames: [EmulationGame] = [] + pomelogames = urls.reduce(into: [EmulationGame]()) { partialResult, element in + let iscustom = element.startAccessingSecurityScopedResource() + let information = AppUI.shared.information(for: element) + let game = EmulationGame(developer: information.developer, fileURL: element, imageData: information.iconData, title: information.title) + if iscustom { + element.stopAccessingSecurityScopedResource() + } + partialResult.append(game) + } + return pomelogames + } + return games(from: urls) + } + + func library() throws -> Core { + func getromsfromdir() throws -> [URL] { + guard let dirContents = FileManager.default.enumerator(at: documentdir, includingPropertiesForKeys: nil, options: []) else { + print("uhoh how unfortunate for some reason FileManager.default.enumerator aint workin") + throw LibManError.ripenum + } + let appui = AppUI.shared + var urls: [URL] = [] + try dirContents.forEach() { files in + if let file = files as? URL { + let getaboutfile = try file.resourceValues(forKeys: [.isRegularFileKey]) + if let isfile = getaboutfile.isRegularFile, isfile { + if ["nca", "nro", "nsp", "nso", "xci"].contains(file.pathExtension.lowercased()) { + urls.append(file) + } + } + } + } + let sdmc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sdmc", conformingTo: .folder) + let sdfolder = sdmc.appendingPathComponent("switch", conformingTo: .folder) + if FileManager.default.fileExists(atPath: sdfolder.path) { + if let dirContents = FileManager.default.enumerator(at: sdmc, includingPropertiesForKeys: nil, options: []) { + try dirContents.forEach() { files in + if let file = files as? URL { + let getaboutfile = try file.resourceValues(forKeys: [.isRegularFileKey]) + if let isfile = getaboutfile.isRegularFile, isfile { + if ["nso", "nro"].contains(file.pathExtension.lowercased()) { + urls.append(file) + } + } + } + } + } + } + appui.insert(games: urls) + return urls + } + + func games(from urls: [URL], core: inout Core) { + core.games = urls.reduce(into: [EmulationGame]()) { partialResult, element in + let iscustom = element.startAccessingSecurityScopedResource() + let information = AppUI.shared.information(for: element) + let game = EmulationGame(developer: information.developer, fileURL: element, imageData: information.iconData, title: information.title) + if iscustom { + element.stopAccessingSecurityScopedResource() + } + partialResult.append(game) + } + } + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + var YuzuCore = Core(games: [], root: directory) + games(from: try getromsfromdir(), core: &YuzuCore) + return YuzuCore + } +} diff --git a/src/ios/FolderMonitor.swift b/src/ios/FolderMonitor.swift new file mode 100644 index 0000000000..ecae4c0857 --- /dev/null +++ b/src/ios/FolderMonitor.swift @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +class FolderMonitor { + private var folderDescriptor: Int32 = -1 + private var folderMonitorSource: DispatchSourceFileSystemObject? + private let folderURL: URL + private let onFolderChange: () -> Void + init(folderURL: URL, onFolderChange: @escaping () -> Void) { + self.folderURL = folderURL + self.onFolderChange = onFolderChange + startMonitoring() + } + private func startMonitoring() { + folderDescriptor = open(folderURL.path, O_EVTONLY) + guard folderDescriptor != -1 else { + print("Failed to open folder descriptor.") + return + } + + folderMonitorSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: folderDescriptor, + eventMask: .write, + queue: DispatchQueue.global() + ) + folderMonitorSource?.setEventHandler { [weak self] in + self?.folderDidChange() + } + folderMonitorSource?.setCancelHandler { + close(self.folderDescriptor) + } + folderMonitorSource?.resume() + } + + private func folderDidChange() { + // Detect the change and call the refreshcore function + print("Folder changed! New file added or removed.") + DispatchQueue.main.async { [weak self] in + self?.onFolderChange() + } + } + deinit { + folderMonitorSource?.cancel() + } +} diff --git a/src/ios/GameButtonListView.swift b/src/ios/GameButtonListView.swift new file mode 100644 index 0000000000..c58d329e72 --- /dev/null +++ b/src/ios/GameButtonListView.swift @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, TechGuy +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import Foundation +import UIKit + +struct GameButtonListView: View { + var game: EmulationGame + @Environment(\.colorScheme) var colorScheme + + var body: some View { + HStack(spacing: 15) { + if let image = UIImage(data: game.imageData) { + Image(uiImage: image) + .resizable() + .frame(width: 60, height: 60) + .cornerRadius(8) + } else { + Image(systemName: "photo") + .resizable() + .frame(width: 60, height: 60) + .cornerRadius(8) + } + + VStack(alignment: .leading, spacing: 4) { + Text(game.title) + .font(.headline) + .foregroundColor(colorScheme == .dark ? Color.white : Color.black) + Text(game.developer) + .font(.subheadline) + .foregroundColor(.gray) + } + Spacer() + } + .padding(.vertical, 8) + } +} diff --git a/src/ios/GameButtonView.swift b/src/ios/GameButtonView.swift new file mode 100644 index 0000000000..fae44544e7 --- /dev/null +++ b/src/ios/GameButtonView.swift @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import Foundation +import UIKit +import UniformTypeIdentifiers +import Combine + +struct GameIconView: View { + var game: EmulationGame + @Binding var selectedGame: EmulationGame? + @State var startgame: Bool = false + @State var timesTapped: Int = 0 + + var isSelected: Bool { + selectedGame == game + } + + var body: some View { + NavigationLink( + destination: EmulationView(game: game).toolbar(.hidden, for: .tabBar), + isActive: $startgame, + label: { + EmptyView() + } + ) + VStack(spacing: 5) { + if isSelected { + Text(game.title) + .foregroundColor(.blue) + .font(.title2) + } + if let uiImage = UIImage(data: game.imageData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(width: isSelected ? 200 : 180, height: isSelected ? 200 : 180) + .cornerRadius(10) + .overlay( + isSelected ? RoundedRectangle(cornerRadius: 10) + .stroke(Color.blue, lineWidth: 5) + : nil + ) + .onTapGesture { + if isSelected { + startgame = true + print(isSelected) + } + if !isSelected { + selectedGame = game + } + } + } else { + Image(systemName: "questionmark") + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .cornerRadius(10) + .onTapGesture { selectedGame = game } + } + } + .frame(width: 200, height: 250) + } +} + +struct BottomMenuView: View { + @State var core: Core + var body: some View { + HStack(spacing: 40) { + Button { + + } label: { + Circle() + .overlay { + Image(systemName: "message").font(.system(size: 30)).foregroundColor(.red) + } + .frame(width: 50, height: 50) + .foregroundColor(Color.init(uiColor: .lightGray)) + } + Button { + + } label: { + Circle() + .overlay { + Image(systemName: "photo").font(.system(size: 30)).foregroundColor(.blue) + } + .frame(width: 50, height: 50) + .foregroundColor(Color.init(uiColor: .lightGray)) + } + NavigationLink(destination: SettingsView(core: core)) { + Circle() + .overlay { + Image(systemName: "gearshape").foregroundColor(Color.init(uiColor: .darkGray)).font(.system(size: 30)) + } + .frame(width: 50, height: 50) + .foregroundColor(Color.init(uiColor: .lightGray)) + } + + Button { + + } label: { + Circle() + .overlay { + Image(systemName: "power").foregroundColor(Color.init(uiColor: .darkGray)).font(.system(size: 30)) + } + .frame(width: 50, height: 50) + .foregroundColor(Color.init(uiColor: .lightGray)) + } + } + .padding(.bottom, 20) + } +} + +struct HomeView: View { + @State private var selectedGame: EmulationGame? = nil + + @State var core: Core + + init(selectedGame: EmulationGame? = nil, core: Core) { + _core = State(wrappedValue: core) + self.selectedGame = selectedGame + refreshcore() + } + + var body: some View { + NavigationStack { + GeometryReader { geometry in + VStack { + GameCarouselView(core: core, selectedGame: $selectedGame) + Spacer() + BottomMenuView(core: core) + } + } + } + .background(Color.gray.opacity(0.1)) + .edgesIgnoringSafeArea(.all) + .onAppear { + refreshcore() + if let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let romsFolderURL = documentsDirectory.appendingPathComponent("roms") + let folderMonitor = FolderMonitor(folderURL: romsFolderURL) { + do { + core = Core(games: [], root: documentsDirectory) + core = try LibraryManager.shared.library() + } catch { + print("Error refreshing core: \(error)") + } + } + } + } + } + + func refreshcore() { + print("Loading library...") + do { + core = try LibraryManager.shared.library() + print(core.games) + } catch { + print("Failed to fetch library: \(error)") + return + } + } +} + + +struct GameCarouselView: View { + // let games: [EmulationGame] + @State var core: Core + @Binding var selectedGame: EmulationGame? + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(core.games) { game in + GameIconView(game: game, selectedGame: $selectedGame) + } + } + } + } +} diff --git a/src/ios/GameListView.swift b/src/ios/GameListView.swift new file mode 100644 index 0000000000..1c8d39e780 --- /dev/null +++ b/src/ios/GameListView.swift @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import Foundation +import UIKit +import UniformTypeIdentifiers +import AppUI + +struct GameListView: View { + @State var core: Core + @State private var searchText = "" + @State var game: Int = 1 + @State var startgame: Bool = false + @Binding var isGridView: Bool + @State var showAlert = false + @State var alertMessage: Alert? = nil + + var body: some View { + let filteredGames = core.games.filter { game in + guard let EmulationGame = game as? PoYuzume else { return false } + return searchText.isEmpty || EmulationGame.title.localizedCaseInsensitiveContains(searchText) + } + + ScrollView { + VStack { + VStack(alignment: .leading) { + + if isGridView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 10) { + ForEach(0.. String { + guard let s = infoDictionary?["CFBundleShortVersionString"] as? String else { + return "Unknown" + } + return s + } +} diff --git a/src/ios/JoystickView.swift b/src/ios/JoystickView.swift new file mode 100644 index 0000000000..24ce8e4739 --- /dev/null +++ b/src/ios/JoystickView.swift @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import SwiftUIJoystick +import AppUI + +public struct Joystick: View { + @State var iscool: Bool? = nil + var id: Int { + if onscreenjoy { + return 8 + } + return 0 + } + @AppStorage("onscreenhandheld") var onscreenjoy: Bool = false + + let appui = AppUI.shared + + @ObservedObject public var joystickMonitor = JoystickMonitor() + private let dragDiameter: CGFloat = 160 + private let shape: JoystickShape = .circle + + public var body: some View { + VStack{ + JoystickBuilder( + monitor: self.joystickMonitor, + width: self.dragDiameter, + shape: .circle, + background: { + // Example Background + RoundedRectangle(cornerRadius: 8).fill(Color.gray.opacity(0)) + }, + foreground: { + // Example Thumb + Circle().fill(Color.gray) + }, + locksInPlace: false) + .onChange(of: self.joystickMonitor.xyPoint) { newValue in + let scaledX = Float(newValue.x) + let scaledY = Float(-newValue.y) // my dumbass broke this by having -y instead of y :/ (well it appears that with the new joystick code, its supposed to be -y) + joystickMonitor.objectWillChange + print("Joystick Position: (\(scaledX), \(scaledY))") + + if iscool != nil { + appui.thumbstickMoved(analog: .right, x: scaledX, y: scaledY, controllerid: id) + } else { + appui.thumbstickMoved(analog: .left, x: scaledX, y: scaledY, controllerid: id) + } + } + } + } +} diff --git a/src/ios/KeyboardHostingController.swift b/src/ios/KeyboardHostingController.swift new file mode 100644 index 0000000000..85e1cd01d0 --- /dev/null +++ b/src/ios/KeyboardHostingController.swift @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +class KeyboardHostingController: UIHostingController { + + override var canBecomeFirstResponder: Bool { + return true + } + + override func viewDidLoad() { + super.viewDidLoad() + becomeFirstResponder() // Make sure the view can become the first responder + } + + override var keyCommands: [UIKeyCommand]? { + return [ + UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: "w", modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: "s", modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: "a", modifierFlags: [], action: #selector(handleKeyCommand)), + UIKeyCommand(input: "d", modifierFlags: [], action: #selector(handleKeyCommand)) + ] + } + + @objc func handleKeyCommand(_ sender: UIKeyCommand) { + if let input = sender.input { + switch input { + case UIKeyCommand.inputUpArrow: + print("Up Arrow Pressed") + case UIKeyCommand.inputDownArrow: + print("Down Arrow Pressed") + case UIKeyCommand.inputLeftArrow: + print("Left Arrow Pressed") + case UIKeyCommand.inputRightArrow: + print("Right Arrow Pressed") + case "w": + print("W Key Pressed") + case "s": + print("S Key Pressed") + case "a": + print("A Key Pressed") + case "d": + print("D Key Pressed") + default: + break + } + } + } +} + + +struct KeyboardSupportView: UIViewControllerRepresentable { + let content: Text + + func makeUIViewController(context: Context) -> KeyboardHostingController { + return KeyboardHostingController(rootView: content) + } + + func updateUIViewController(_ uiViewController: KeyboardHostingController, context: Context) { + // Handle any updates needed + } +} + +struct KeyboardView: View { + var body: some View { + KeyboardSupportView(content: Text("")) + } +} diff --git a/src/ios/LibraryView.swift b/src/ios/LibraryView.swift new file mode 100644 index 0000000000..c85b39608d --- /dev/null +++ b/src/ios/LibraryView.swift @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import CryptoKit +import AppUI + +struct LibraryView: View { + @Binding var core: Core + @State var isGridView: Bool = true + @State var doesitexist = (false, false) + @State var importedgame: EmulationGame? = nil + @State var importgame: Bool = false + @State var isimportingfirm: Bool = false + @State var launchGame: Bool = false + var body: some View { + NavigationStack { + if let importedgame = importedgame { + NavigationLink( + isActive: $launchGame, + destination: { + EmulationView(game: importedgame).toolbar(.hidden, for: .tabBar) + }, + label: { + EmptyView() // This keeps the link hidden + } + ) + } + + VStack { + if doesitexist.0, doesitexist.1 { + HomeView(core: core) + } else { + let (doesKeyExist, doesProdExist) = doeskeysexist() + ScrollView { + Text("You Are Missing These Files:") + .font(.headline) + .foregroundColor(.red) + HStack { + if !doesProdExist { + Text("Prod.keys") + .font(.subheadline) + .foregroundColor(.red) + } + if !doesKeyExist { + Text("Title.keys") + .font(.subheadline) + .foregroundColor(.red) + } + } + Text("These goes into the Keys folder") + .font(.caption) + .foregroundColor(.red) + .padding(.bottom) + + if !LibraryManager.shared.homebrewroms().isEmpty { + Text("Homebrew Roms:") + .font(.headline) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 10) { + ForEach(LibraryManager.shared.homebrewroms()) { game in + NavigationLink(destination: EmulationView(game: game).toolbar(.hidden, for: .tabBar)) { + // GameButtonView(game: game) + // .frame(maxWidth: .infinity, minHeight: 200) + } + .contextMenu { + NavigationLink(destination: EmulationView(game: game)) { + Text("Launch") + } + } + } + } + } + } + .refreshable { + doesitexist = doeskeysexist() + } + + + } + + } + .fileImporter(isPresented: $isimportingfirm, allowedContentTypes: [.zip], onCompletion: { result in + switch result { + case .success(let elements): + core.AddFirmware(at: elements) + case .failure(let error): + + print(error.localizedDescription) + } + }) + .fileImporter(isPresented: $importgame, allowedContentTypes: [.item], onCompletion: { result in + switch result { + case .success(let elements): + let iscustom = elements.startAccessingSecurityScopedResource() + let information = AppUI.shared.information(for: elements) + + let game = EmulationGame(developer: information.developer, fileURL: elements, + imageData: information.iconData, + title: information.title) + + importedgame = game + + + DispatchQueue.main.async { + + if iscustom { + elements.stopAccessingSecurityScopedResource() + } + + launchGame = true + } + case .failure(let error): + + print(error.localizedDescription) + } + }) + .onAppear() { + doesitexist = doeskeysexist() + } + .navigationBarTitle("Library", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { // why did this take me so long to figure out lmfao + Button(action: { + isGridView.toggle() + }) { + Image(systemName: isGridView ? "rectangle.grid.1x2" : "square.grid.2x2") + .imageScale(.large) + .padding() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { // funsies + Menu { + Button(action: { + importgame = true // this part took a while + + }) { + Text("Launch Game") + } + + Button(action: { + isimportingfirm = true + }) { + Text("Import Firmware") + } + } label: { + Image(systemName: "plus.circle.fill") + .imageScale(.large) + .padding() + } + + } + } + } + } + + + func doeskeysexist() -> (Bool, Bool) { + var doesprodexist = false + var doestitleexist = false + + + let title = core.root.appendingPathComponent("keys").appendingPathComponent("title.keys") + let prod = core.root.appendingPathComponent("keys").appendingPathComponent("prod.keys") + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + + if fileManager.fileExists(atPath: prod.path) { + doesprodexist = true + } else { + print("File does not exist") + } + + if fileManager.fileExists(atPath: title.path) { + doestitleexist = true + } else { + print("File does not exist") + } + + return (doestitleexist, doesprodexist) + } +} + +func getDeveloperNames() -> String { + guard let s = infoDictionary?["CFBundleIdentifier"] as? String else { + return "Unknown" + } + return s +} diff --git a/src/ios/MetalView.swift b/src/ios/MetalView.swift new file mode 100644 index 0000000000..c6a746e9f2 --- /dev/null +++ b/src/ios/MetalView.swift @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import Metal +import AppUI + +struct MetalView: UIViewRepresentable { + let device: MTLDevice? + let configure: (UIView) -> Void + + func makeUIView(context: Context) -> EmulationScreenView { + let view = EmulationScreenView() + configure(view.primaryScreen) + return view + } + + func updateUIView(_ uiView: EmulationScreenView, context: Context) { + // + } +} diff --git a/src/ios/NavView.swift b/src/ios/NavView.swift new file mode 100644 index 0000000000..b989dd5925 --- /dev/null +++ b/src/ios/NavView.swift @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import AppUI + +struct NavView: View { + @Binding var core: Core + @State private var selectedTab = 0 + var body: some View { + TabView(selection: $selectedTab) { + LibraryView(core: $core) + .tabItem { Label("Library", systemImage: "rectangle.on.rectangle") } + .tag(0) + BootOSView(core: $core, currentnavigarion: $selectedTab) + .toolbar(.hidden, for: .tabBar) + .tabItem { Label("Boot OS", systemImage: "house") } + .tag(1) + SettingsView(core: core) + .tabItem { Label("Settings", systemImage: "gear") } + .tag(2) + } + } +} diff --git a/src/ios/SettingsView.swift b/src/ios/SettingsView.swift new file mode 100644 index 0000000000..dca90e7574 --- /dev/null +++ b/src/ios/SettingsView.swift @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2024 Pomelo, Stossy11 +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct SettingsView: View { + @State var core: Core + @State var showprompt = false + + @AppStorage("icon") var iconused = 1 + var body: some View { + NavigationStack { + + } + } +}