├── Shared ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 512.png │ │ ├── 128@2x.png │ │ ├── 16@2x.png │ │ ├── 256@2x.png │ │ ├── 32@2x.png │ │ ├── 512@2x.png │ │ ├── iPad_App_76_1x.png │ │ ├── iPad_App_76_2x.png │ │ ├── iPhone_App_60_2x.png │ │ ├── iPhone_App_60_3x.png │ │ ├── App_store_1024_1x.png │ │ ├── iPad_Pro_App_83.5_2x.png │ │ ├── iPad_Settings_29_1x.png │ │ ├── iPad_Settings_29_2x.png │ │ ├── iPad_Spotlight_40_1x.png │ │ ├── iPad_Spotlight_40_2x.png │ │ ├── iPhone_Settings_29_2x.png │ │ ├── iPhone_Settings_29_3x.png │ │ ├── iPhone_Spotlight_40_2x.png │ │ ├── iPhone_Spotlight_40_3x.png │ │ ├── iPad_Notifications_20_1x.png │ │ ├── iPad_Notifications_20_2x.png │ │ ├── iPhone_Notifications_20_2x.png │ │ ├── iPhone_Notifications_20_3x.png │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── LaunchScreenBackground.colorset │ │ └── Contents.json ├── Extensions │ ├── Comparable.swift │ └── ObservableObject.swift ├── Theme.swift ├── PolygonShape.swift ├── EndpointWrapper.swift ├── Networking.swift └── ControllerProtocol.swift ├── WidgetExtension ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── ActivityAttributes.swift ├── WidgetExtensionBundle.swift ├── AppIntent.swift ├── Info.plist ├── WidgetExtension.swift └── WidgetExtensionLiveActivity.swift ├── iOS ├── Storage.swift ├── IdleManager.swift ├── Views │ ├── LightView.swift │ ├── PressAction.swift │ ├── PingView.swift │ ├── SettingsView.swift │ ├── HelpView.swift │ ├── GCButton.swift │ ├── ContentView.swift │ ├── JoystickView.swift │ ├── ServerBrowserView.swift │ └── ControllerView.swift ├── ActivityManager.swift ├── Intents.swift ├── Info.plist ├── DolphinControllerApp.swift ├── Haptics.swift └── Client.swift ├── PRIVACY.md ├── macOS ├── macOS.entitlements ├── ControllerFilePipe.swift ├── Info.plist ├── Views │ ├── NetworkingInstructionsView.swift │ ├── ControllerPlugView.swift │ └── ContentView.swift ├── DolphinControllerApp.swift ├── ControllerConnection.swift └── Server.swift ├── DolphinController.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ └── DolphinController (macOS).xcscheme └── project.pbxproj ├── CONTRIBUTING.md ├── .gitignore ├── Tests iOS ├── Info.plist └── Tests_iOS.swift ├── Tests macOS ├── Info.plist └── Tests_macOS.swift ├── GCPadNew.ini └── README.md /Shared/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WidgetExtension/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iOS/Storage.swift: -------------------------------------------------------------------------------- 1 | enum StorageKeys: String { 2 | case lastUsedServer = "lastUsedServer" 3 | case lastManualAddress = "lastManualAddress" 4 | } 5 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/128@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/16@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/256@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/32@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/512@2x.png -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy policy 2 | 3 | No data, aside from standard Apple usage and crash statistics, is collected. Non-identifying data is sent between the iOS client and macOS server. 4 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_App_76_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_App_76_1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_App_76_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_App_76_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/App_store_1024_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/App_store_1024_1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Pro_App_83.5_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Pro_App_83.5_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Settings_29_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Settings_29_1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Settings_29_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Settings_29_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Spotlight_40_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Spotlight_40_1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Spotlight_40_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Spotlight_40_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Notifications_20_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Notifications_20_1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPad_Notifications_20_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPad_Notifications_20_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/dolphin-controller/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_3x.png -------------------------------------------------------------------------------- /WidgetExtension/ActivityAttributes.swift: -------------------------------------------------------------------------------- 1 | import ActivityKit 2 | 3 | struct WidgetExtensionAttributes: ActivityAttributes { 4 | public struct ContentState: Codable, Hashable { 5 | var slot: UInt8 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /macOS/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /DolphinController.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Shared/Extensions/Comparable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Comparable { 4 | func clamped(to limits: ClosedRange) -> Self { 5 | return min(max(self, limits.lowerBound), limits.upperBound) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetExtensionBundle.swift: -------------------------------------------------------------------------------- 1 | import WidgetKit 2 | import SwiftUI 3 | 4 | @main 5 | struct WidgetExtensionBundle: WidgetBundle { 6 | var body: some Widget { 7 | WidgetExtensionLiveActivity() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /WidgetExtension/AppIntent.swift: -------------------------------------------------------------------------------- 1 | import WidgetKit 2 | import AppIntents 3 | 4 | struct ConfigurationAppIntent: WidgetConfigurationIntent { 5 | static var title: LocalizedStringResource = "Dolphin Ctrl" 6 | static var description = IntentDescription("A B buttons") 7 | } 8 | -------------------------------------------------------------------------------- /WidgetExtension/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution notes 2 | 3 | ## Releasing 4 | 5 | ### Server 6 | 7 | 1. Archive project in XCode 8 | 2. From Organizer, distribute the archive choosing Direct Distribution 9 | 3. Once notarized, Organizer will give an option to Export 10 | 4. Compress the exported .app file, and attach to a new GitHub release 11 | -------------------------------------------------------------------------------- /DolphinController.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /WidgetExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | exports 2 | assets 3 | node_modules 4 | 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Gcc Patch 30 | /*.gcno 31 | -------------------------------------------------------------------------------- /macOS/ControllerFilePipe.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | 4 | private var newline: UInt8 = 0x0A 5 | 6 | final class ControllerFilePipe { 7 | private var outputStream: OutputStream 8 | 9 | init(index: UInt8) throws { 10 | self.outputStream = try createPipe(index: index) 11 | DispatchQueue.global().async { 12 | self.outputStream.open() 13 | } 14 | } 15 | 16 | deinit { 17 | self.outputStream.close() 18 | } 19 | 20 | func streamText(data: Data) throws { 21 | if !self.outputStream.hasSpaceAvailable { 22 | return 23 | } 24 | self.outputStream.write([UInt8](data), maxLength: data.count) 25 | self.outputStream.write(&newline, maxLength: 1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /iOS/IdleManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import Combine 4 | 5 | class IdleManager { 6 | static let storage = UserDefaults.standard 7 | 8 | var client: Client 9 | 10 | private var cancellable: AnyCancellable? = nil 11 | 12 | init(client: Client) { 13 | self.client = client 14 | 15 | self.cancellable = client.connection.publisher.sink { _ in 16 | self.update() 17 | } 18 | self.update() 19 | } 20 | 21 | func update() { 22 | DispatchQueue.main.async { 23 | let settingVal = Self.storage.value(forKey: "keepScreenAwake") as? Bool ?? true 24 | let hasConnection = self.client.connection != nil 25 | UIApplication.shared.isIdleTimerDisabled = settingVal && hasConnection 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xBC", 9 | "green" : "0x73", 10 | "red" : "0x6A" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xBC", 27 | "green" : "0x73", 28 | "red" : "0x6A" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iOS/Views/LightView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LightView: View { 4 | let assigned: Bool 5 | let available: Bool? 6 | 7 | var body: some View { 8 | var color = GameCubeColors.lightGray.opacity(0.5) 9 | var icon = "circle.dashed" 10 | if assigned { 11 | icon = "circle.fill" // iOS 15 "circle.inset.filled" 12 | color = Color(red: 163/255, green: 252/255, blue: 255/255) 13 | } else if available == true { 14 | icon = "circle" 15 | color = GameCubeColors.lightGray 16 | } else if available == false { 17 | icon = "slash.circle" // iOS 15 "circle.slash" 18 | color = GameCubeColors.lightGray 19 | } 20 | let light = Image(systemName: icon) 21 | .resizable() 22 | .foregroundColor(color) 23 | .frame(width: 18, height: 18) 24 | return light 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /iOS/Views/PressAction.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PressActions: ViewModifier { 4 | var onPress: () -> Void 5 | var onRelease: () -> Void 6 | 7 | @State var pressed = false 8 | 9 | func body(content: Content) -> some View { 10 | content 11 | .simultaneousGesture( 12 | DragGesture(minimumDistance: 0) 13 | .onChanged({ test in 14 | if !pressed { 15 | onPress() 16 | pressed = true 17 | } 18 | }) 19 | .onEnded({ _ in 20 | onRelease() 21 | pressed = false 22 | }) 23 | ) 24 | } 25 | } 26 | 27 | extension View { 28 | func pressAction(onPress: @escaping (() -> Void), onRelease: @escaping (() -> Void)) -> some View { 29 | modifier(PressActions(onPress: { 30 | onPress() 31 | }, onRelease: { 32 | onRelease() 33 | })) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /iOS/Views/PingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PingView: View { 4 | static private let pingFormatter: NumberFormatter = { 5 | let formatter = NumberFormatter() 6 | formatter.numberStyle = .decimal 7 | formatter.maximumFractionDigits = 0 8 | return formatter 9 | }() 10 | 11 | var ping: TimeInterval? 12 | 13 | var body: some View { 14 | guard let ping = self.ping else { 15 | return AnyView(EmptyView()) 16 | } 17 | let pingMilliseconds = ping.truncatingRemainder(dividingBy: 1) * 1000 18 | guard let pingString = Self.pingFormatter.string(from: NSNumber(value: pingMilliseconds)) else { 19 | return AnyView(EmptyView()) 20 | } 21 | return AnyView( 22 | Text(pingString) 23 | .font(.callout.monospacedDigit()) 24 | .foregroundColor(.white.opacity(0.6)) 25 | .blendMode(.screen) 26 | .help("Server ping in milliseconds") 27 | ) 28 | } 29 | } 30 | 31 | #Preview { 32 | PingView() 33 | } 34 | -------------------------------------------------------------------------------- /iOS/ActivityManager.swift: -------------------------------------------------------------------------------- 1 | import ActivityKit 2 | 3 | @available(iOS 16.2, *) 4 | class ActivityManager { 5 | static var activity: Activity? = nil 6 | 7 | static func reset() async { 8 | for activity in Activity.activities { 9 | await activity.end(nil, dismissalPolicy: .immediate) 10 | } 11 | } 12 | 13 | static func update(slot: UInt8?) async { 14 | if let slot = slot { 15 | let content = ActivityContent(state: WidgetExtensionAttributes.ContentState(slot: slot), staleDate: nil) 16 | if let activeActivity = self.activity { 17 | await activeActivity.update(content) 18 | } else { 19 | do { 20 | Self.activity = try Activity.request(attributes: WidgetExtensionAttributes(), content: content) 21 | } catch { 22 | print("error: \(error)") 23 | } 24 | } 25 | } else { 26 | await Self.activity?.end(nil, dismissalPolicy: .immediate) 27 | Self.activity = nil 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Shared/Extensions/ObservableObject.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher { 5 | func forwardChanges( 6 | to forwardTo: T 7 | ) -> AnyCancellable where T.ObjectWillChangePublisher == ObservableObjectPublisher { 8 | return self.objectWillChange.sink { _ in 9 | DispatchQueue.main.async { 10 | forwardTo.objectWillChange.send() 11 | } 12 | } 13 | } 14 | 15 | func forwardChanges( 16 | from forwardFrom: T 17 | ) -> AnyCancellable where T.ObjectWillChangePublisher == ObservableObjectPublisher { 18 | return forwardFrom.objectWillChange.sink { _ in 19 | DispatchQueue.main.async { 20 | self.objectWillChange.send() 21 | } 22 | } 23 | } 24 | } 25 | 26 | class ObservableArray: ObservableObject { 27 | @Published var contents: Array 28 | 29 | init(_ contents: Array) { 30 | self.contents = contents 31 | } 32 | 33 | init() { 34 | self.contents = Array() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Shared/Theme.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class GameCubeColors { 4 | static let lightGray = Color(red: 221/256, green: 218/256, blue: 231/256) 5 | static let zColor = Color(red: 72/256, green: 100/256, blue: 226/256) 6 | static let green = Color(red: 55/256, green: 199/256, blue: 195/256) 7 | static let red = Color(red: 232/256, green: 16/256, blue: 39/256) 8 | static let yellow = Color(red: 254/256, green: 217/256, blue: 39/256) 9 | static let purple = Color(red: 106/256, green: 115/256, blue: 188/256) 10 | } 11 | 12 | extension Font { 13 | // https://gist.github.com/tadija/cb4ec0cbf0a89886d488d1d8b595d0e9 14 | static func gameCubeController(size: CGFloat) -> Font { 15 | Self.custom("Futura-CondensedMedium", size: size) 16 | } 17 | } 18 | 19 | struct GCLabel: ViewModifier { 20 | var size: CGFloat 21 | 22 | func body(content: Content) -> some View { 23 | content 24 | .font(.gameCubeController(size: size)) 25 | .foregroundColor(.black.opacity(0.25)) 26 | .blendMode(.multiply) 27 | } 28 | } 29 | 30 | extension View { 31 | func gcLabel(size: CGFloat = 30) -> some View { 32 | modifier(GCLabel(size: size)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /iOS/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | @EnvironmentObject private var client: Client 5 | @Environment(\.presentationMode) private var presentationMode 6 | @AppStorage("keepScreenAwake") private var keepScreenAwake = true 7 | @AppStorage("joystickHapticsEnabled") private var joystickHapticsEnabled = true 8 | @AppStorage("showPing") private var showPing = false 9 | 10 | var body: some View { 11 | NavigationView { 12 | List { 13 | Toggle("Keep Screen Awake (when connected to server)", isOn: $keepScreenAwake) 14 | .onChange(of: keepScreenAwake) { newValue in 15 | client.idleManager?.update() 16 | } 17 | Toggle("Continuous Joystick Haptics", isOn: $joystickHapticsEnabled) 18 | Toggle("Display Ping", isOn: $showPing) 19 | 20 | HelpView() 21 | } 22 | .navigationBarTitle("Settings") 23 | .navigationBarItems(trailing: Button("Close", action: { 24 | self.presentationMode.wrappedValue.dismiss() 25 | })) 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | SettingsView() 32 | } 33 | -------------------------------------------------------------------------------- /iOS/Intents.swift: -------------------------------------------------------------------------------- 1 | import AppIntents 2 | import UIKit 3 | 4 | let AIntentNotificationName = Notification.Name(rawValue: "PressAIntent") 5 | 6 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) 7 | struct PressAIntent: AppIntent { 8 | 9 | static var title: LocalizedStringResource = "A Button" 10 | static var description = IntentDescription("Press the A Button") 11 | static var isDiscoverable = false 12 | 13 | func perform() async throws -> some IntentResult { 14 | await MainActor.run { 15 | NotificationCenter.default.post(name: AIntentNotificationName, object: nil) 16 | } 17 | return .result() 18 | } 19 | } 20 | 21 | let BIntentNotificationName = Notification.Name(rawValue: "PressBIntent") 22 | 23 | @available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) 24 | struct PressBIntent: AppIntent { 25 | 26 | static var title: LocalizedStringResource = "B Button" 27 | static var description = IntentDescription("Press the B Button") 28 | static var isDiscoverable = false 29 | 30 | func perform() async throws -> some IntentResult { 31 | await MainActor.run { 32 | NotificationCenter.default.post(name: BIntentNotificationName, object: nil) 33 | } 34 | return .result() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetExtension.swift: -------------------------------------------------------------------------------- 1 | import WidgetKit 2 | import SwiftUI 3 | 4 | struct Provider: AppIntentTimelineProvider { 5 | func placeholder(in context: Context) -> SimpleEntry { 6 | SimpleEntry(date: Date(), slot: nil, configuration: ConfigurationAppIntent()) 7 | } 8 | 9 | func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { 10 | SimpleEntry(date: Date(), slot: 1, configuration: configuration) 11 | } 12 | 13 | func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { 14 | var entries: [SimpleEntry] = [] 15 | 16 | // Generate a timeline consisting of five entries an hour apart, starting from the current date. 17 | let currentDate = Date() 18 | for hourOffset in 0 ..< 5 { 19 | let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! 20 | let entry = SimpleEntry(date: entryDate, slot: 1, configuration: configuration) 21 | entries.append(entry) 22 | } 23 | 24 | return Timeline(entries: entries, policy: .atEnd) 25 | } 26 | } 27 | 28 | struct SimpleEntry: TimelineEntry { 29 | var date: Date 30 | 31 | let slot: UInt8? 32 | let configuration: ConfigurationAppIntent 33 | } 34 | 35 | -------------------------------------------------------------------------------- /macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.2 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.games 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSBonjourServices 28 | 29 | _dolphinC._tcp 30 | _dolphinC._udp 31 | 32 | NSHumanReadableCopyright 33 | 2021 Cameron Little 34 | NSLocalNetworkUsageDescription 35 | Connects to mobile devices as GameCube controllers. 36 | 37 | 38 | -------------------------------------------------------------------------------- /Shared/PolygonShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // https://www.hackingwithswift.com/quick-start/swiftui/how-to-draw-polygons-and-stars 4 | 5 | struct Polygon: Shape { 6 | let corners: Int 7 | 8 | func path(in rect: CGRect) -> Path { 9 | guard corners >= 2 else { return Path() } 10 | 11 | let center = CGPoint(x: rect.width / 2, y: rect.height / 2) 12 | var currentAngle = -CGFloat.pi / 2 13 | let angleAdjustment = .pi * 2 / CGFloat(corners) 14 | 15 | var path = Path() 16 | path.move(to: CGPoint(x: center.x * cos(currentAngle), y: center.y * sin(currentAngle))) 17 | 18 | // track the lowest point we draw to, so we can center later 19 | var bottomEdge: CGFloat = 0 20 | 21 | for _ in 0.. bottomEdge { 31 | bottomEdge = bottom 32 | } 33 | 34 | currentAngle += angleAdjustment 35 | } 36 | 37 | let unusedSpace = (rect.height / 2 - bottomEdge) / 2 38 | 39 | let transform = CGAffineTransform(translationX: center.x, y: center.y + unusedSpace) 40 | return path.applying(transform) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests iOS/Tests_iOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tests_iOS.swift 3 | // Tests iOS 4 | // 5 | // Created by Cameron Little on 2021-07-30. 6 | // 7 | 8 | import XCTest 9 | 10 | class Tests_iOS: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests macOS/Tests_macOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tests_macOS.swift 3 | // Tests macOS 4 | // 5 | // Created by Cameron Little on 2021-07-30. 6 | // 7 | 8 | import XCTest 9 | 10 | class Tests_macOS: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /iOS/Views/HelpView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | let serverInstallURL = URL(string: "https://github.com/apexskier/dolphin-controller")! 5 | let serverInstallURLDescription = "Server Installation Instructions" 6 | let appURL = URL(string: "https://apps.apple.com/us/app/id1584272645")! 7 | 8 | struct HelpView: View { 9 | var body: some View { 10 | Section( 11 | header: Text("No server?"), 12 | footer: Text("You'll need to install and run the server alongside Dolphin on your Mac.") 13 | ) { 14 | Link(serverInstallURLDescription, destination: serverInstallURL) 15 | if #available(iOS 16.0, *) { 16 | ShareLink( 17 | item: serverInstallURL, 18 | subject: Text("Dolphin Controller Server"), 19 | message: Text("Follow this link to install the Dolphin Controller server on your Mac."), 20 | label: { 21 | Text("\(Image(systemName: "square.and.arrow.up")) Share \(serverInstallURLDescription)") 22 | } 23 | ) 24 | ShareLink( 25 | item: appURL, 26 | subject: Text("Dolphin Controller App"), 27 | message: Text("Follow this link to install the Dolphin Controller app on your iOS device."), 28 | label: { 29 | Text("\(Image(systemName: "square.and.arrow.up")) Share iOS App") 30 | } 31 | ) 32 | } else { 33 | Link("iOS App", destination: appURL) 34 | } 35 | } 36 | } 37 | } 38 | 39 | #Preview { 40 | HelpView() 41 | } 42 | -------------------------------------------------------------------------------- /macOS/Views/NetworkingInstructionsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct NetworkingInstructionsView: View { 4 | @Environment(\.presentationMode) var presentationMode 5 | @EnvironmentObject private var server: Server 6 | 7 | private let portFormatter: NumberFormatter = { 8 | let f = NumberFormatter() 9 | f.numberStyle = .none 10 | f.usesGroupingSeparator = false 11 | return f 12 | }() 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 4) { 16 | Text("Networking") 17 | .font(.title) 18 | if let port = server.port?.rawValue, 19 | let formattedPort = portFormatter.string(from: NSNumber(value: port)) { 20 | Text("From your local network (e.g. on the same Wi-Fi connection), the server should automatically be discovered. To connect from across the internet, you must forward the port \(formattedPort).").fixedSize(horizontal: false, vertical: true) 21 | } else { 22 | Text("No port has been allocated for the server yet.") 23 | } 24 | HStack { 25 | if let port = server.port?.rawValue, 26 | let formattedPort = portFormatter.string(from: NSNumber(value: port)) { 27 | Button("Copy Port") { 28 | NSPasteboard.general.clearContents() 29 | if !NSPasteboard.general.setString(formattedPort, forType: .string) { 30 | print("Failed to copy") 31 | } 32 | } 33 | } 34 | if (presentationMode.wrappedValue.isPresented) { 35 | Button("Close") { 36 | presentationMode.wrappedValue.dismiss() 37 | } 38 | .keyboardShortcut(.cancelAction) 39 | } 40 | } 41 | } 42 | // .textSelection(.enabled) // iOS 15 43 | .allowsTightening(true) 44 | .lineLimit(nil) 45 | .frame(minWidth: 100, idealWidth: 420, minHeight: 100) 46 | } 47 | } 48 | 49 | #Preview { 50 | NetworkingInstructionsView() 51 | } 52 | -------------------------------------------------------------------------------- /iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Dolphin Ctrl 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.2 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSBonjourServices 26 | 27 | _dolphinC._tcp 28 | _dolphinC._udp 29 | 30 | NSLocalNetworkUsageDescription 31 | Connect to a host computer as a GameCube controller. 32 | UIApplicationSceneManifest 33 | 34 | UIApplicationSupportsMultipleScenes 35 | 36 | 37 | UIApplicationSupportsIndirectInputEvents 38 | 39 | UILaunchScreen 40 | 41 | UIColorName 42 | LaunchScreenBackground 43 | 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UIRequiresFullScreen 49 | 50 | UIStatusBarHidden 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | UISupportedInterfaceOrientations~ipad 58 | 59 | UIInterfaceOrientationPortrait 60 | UIInterfaceOrientationPortraitUpsideDown 61 | UIInterfaceOrientationLandscapeLeft 62 | UIInterfaceOrientationLandscapeRight 63 | 64 | NSSupportsLiveActivities 65 | 66 | ITSAppUsesNonExemptEncryption 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /iOS/DolphinControllerApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreHaptics 3 | import Combine 4 | import Foundation 5 | import Network 6 | import NetworkExtension 7 | 8 | @main 9 | struct DolphinControllerApp: App { 10 | @ObservedObject var client = Client() 11 | @State var shouldAutoReconnect: Bool = true 12 | 13 | var body: some Scene { 14 | WindowGroup { 15 | ZStack { 16 | GameCubeColors.purple.ignoresSafeArea() 17 | ContentView( 18 | shouldAutoReconnect: $shouldAutoReconnect 19 | ) 20 | .environmentObject(client) 21 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in 22 | if shouldAutoReconnect { 23 | client.reconnect() 24 | } 25 | } 26 | .onReceive(NotificationCenter.default.publisher(for: AIntentNotificationName), perform: { _ in 27 | print("press A intent handler") 28 | client.send("PRESS A") 29 | DispatchQueue.main.async { 30 | let t = Timer.scheduledTimer( 31 | withTimeInterval: 0.2, 32 | repeats: false 33 | ) { _ in 34 | client.send("RELEASE A") 35 | } 36 | t.fire() 37 | } 38 | }) 39 | .onReceive(NotificationCenter.default.publisher(for: BIntentNotificationName), perform: { _ in 40 | print("press B intent handler") 41 | client.send("PRESS B") 42 | DispatchQueue.main.async { 43 | let t = Timer.scheduledTimer( 44 | withTimeInterval: 0.2, 45 | repeats: false 46 | ) { _ in 47 | client.send("RELEASE B") 48 | } 49 | t.fire() 50 | } 51 | }) 52 | } 53 | .ignoresSafeArea(edges: .top) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Shared/EndpointWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | 4 | /// EndpointWrapper provides a type wrapping a NWEndpoint that that can be serialized and deserialized. 5 | class EndpointWrapper: NSObject, NSCoding { 6 | private enum EndpointType: UInt8 { 7 | case hostPort 8 | case service 9 | case unix 10 | case url 11 | } 12 | 13 | private static let _typeKey = "__endpoint_type" 14 | 15 | func encode(with coder: NSCoder) { 16 | switch endpoint { 17 | case .service(name: let name, type: let type, domain: let domain, interface: _): 18 | coder.encode(EndpointType.service.rawValue, forKey: Self._typeKey) 19 | coder.encode(name, forKey: "name") 20 | coder.encode(type, forKey: "type") 21 | coder.encode(domain, forKey: "domain") 22 | case .hostPort(host: let host, port: let port): 23 | coder.encode(EndpointType.hostPort.rawValue, forKey: Self._typeKey) 24 | coder.encode(host.debugDescription, forKey: "host") 25 | coder.encode(port.rawValue, forKey: "port") 26 | default: 27 | fatalError("unknown NWEndpoint type") 28 | } 29 | } 30 | 31 | required convenience init?(coder: NSCoder) { 32 | guard let endpointTypeRaw = coder.decodeObject(forKey: Self._typeKey) as? UInt8, 33 | let endpointType = EndpointType(rawValue: endpointTypeRaw) else { 34 | return nil 35 | } 36 | switch endpointType { 37 | case .service: 38 | guard let name = coder.decodeObject(forKey: "name") as? String, 39 | let type = coder.decodeObject(forKey: "type") as? String, 40 | let domain = coder.decodeObject(forKey: "domain") as? String else { 41 | return nil 42 | } 43 | self.init(NWEndpoint.service(name: name, type: type, domain: domain, interface: nil)) 44 | case .hostPort: 45 | guard let host = coder.decodeObject(forKey: "host") as? String, 46 | let portInt = coder.decodeObject(forKey: "port") as? UInt16, 47 | let port = NWEndpoint.Port(rawValue: portInt) else { 48 | return nil 49 | } 50 | self.init(NWEndpoint.hostPort(host: .init(host), port: port)) 51 | default: 52 | return nil 53 | } 54 | } 55 | 56 | let endpoint: NWEndpoint 57 | 58 | init(_ endpoint: NWEndpoint) { 59 | self.endpoint = endpoint 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /iOS/Views/GCButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreImage 3 | import CoreImage.CIFilterBuiltins 4 | 5 | let buttonTexture: CIImage = { 6 | let colorNoise = CIFilter.randomGenerator() 7 | let grayscale = CIFilter.colorMonochrome() 8 | grayscale.inputImage = colorNoise.outputImage! 9 | let opacity = CIFilter.colorMatrix() 10 | opacity.inputImage = grayscale.outputImage! 11 | opacity.aVector = CIVector(x: 0, y: 0, z: 0, w: 0.1) 12 | return opacity.outputImage! 13 | }() 14 | 15 | // let blend = CIFilter.overlayBlendMode() 16 | // blend.inputImage = buttonTexture 17 | // blend.backgroundImage = CIImage(color: CIColor(cgColor: color.cgColor!)) 18 | // let image = blend.outputImage! 19 | 20 | // let finalImage = image.cropped(to: CGRect(x: 0, y: 0, width: width ?? 1, height: height ?? 1)) 21 | // let cgImage = CIContext().createCGImage(finalImage, from: finalImage.extent)! 22 | 23 | extension View { 24 | func innerShadow(using shape: S, angle: Angle = .degrees(0), color: Color = .black, width: CGFloat = 6, blur: CGFloat = 6) -> some View { 25 | let finalX = CGFloat(cos(angle.radians - .pi / 2)) 26 | let finalY = CGFloat(sin(angle.radians - .pi / 2)) 27 | return self 28 | .overlay( 29 | shape 30 | .stroke(color, lineWidth: width) 31 | .offset(x: finalX * width * 0.6, y: finalY * width * 0.6) 32 | .blur(radius: blur) 33 | .mask(shape) 34 | ) 35 | } 36 | } 37 | 38 | struct GCCButton: ButtonStyle where S: Shape { 39 | var color: Color 40 | var width: CGFloat? = nil 41 | var height: CGFloat? = nil 42 | var shape: S 43 | var fontSize: CGFloat = 30 44 | 45 | func makeBody(configuration: Configuration) -> some View { 46 | return configuration.label 47 | .gcLabel(size: fontSize) 48 | .padding() 49 | .frame(width: width, height: height) 50 | .background(color) 51 | .brightness(configuration.isPressed ? -0.075 : 0.001) 52 | .clipShape(shape) 53 | .contentShape(shape) 54 | .offset(x: 0, y: configuration.isPressed ? 1 : 0) 55 | .shadow(color: Color.black.opacity(0.3), radius: configuration.isPressed ? 1 : 2, x: 0, y: 1) 56 | } 57 | } 58 | 59 | extension GCCButton where S == Circle { 60 | init(color: Color, width: CGFloat = 42, height: CGFloat = 42, fontSize: CGFloat = 30) { 61 | self.init(color: color, width: width, height: height, shape: Circle(), fontSize: fontSize) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /macOS/Views/ControllerPlugView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private struct ControllerDots: View { 4 | var index: UInt8 5 | 6 | var body: some View { 7 | HStack(spacing: 3) { 8 | ForEach(0.. Path { 20 | Path { path in 21 | let width = rect.width 22 | let height = rect.height 23 | 24 | path.addArc( 25 | center: CGPoint(x: width * 0.5, y: height * 0.5), 26 | radius: width * 0.5, 27 | startAngle: .degrees(-50 - 90), 28 | endAngle: .degrees(50 - 90), 29 | clockwise: true 30 | ) 31 | path.closeSubpath() 32 | } 33 | } 34 | } 35 | 36 | let dotsHelpFormatter: NumberFormatter = { 37 | let formatter = NumberFormatter() 38 | formatter.numberStyle = .spellOut 39 | return formatter 40 | }() 41 | 42 | struct ControllerPlugView: View { 43 | var index: UInt8 44 | var connected: Bool 45 | 46 | var body: some View { 47 | VStack(spacing: dotVerticalSpace) { 48 | ControllerDots(index: index) 49 | ZStack { 50 | Circle() 51 | .fill(Color(white: 0.3)) 52 | .frame(width: 36, height: 36) 53 | if connected { 54 | Circle() 55 | .fill(GameCubeColors.purple) 56 | .frame(width: 30, height: 30) 57 | ZStack { // this pulls the "cord" (rounded rect) out of the layout flow 58 | RoundedRectangle(cornerRadius: 12) 59 | .fill(Color(white: 0.2)) 60 | .frame(width: 12, height: 2000) // I could probably do something fancy with a geometry reader and passing properties through whatever that thing is to automatically make this the size of the window but this should be fine 61 | .offset(x: 0, y: 1000 - 6) 62 | } 63 | .frame(width: 30, height: 30) 64 | } else { 65 | PlugShape() 66 | .fill(Color.black) 67 | .frame(width: 24, height: 24) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | #Preview { 75 | ControllerPlugView(index: 1, connected: true) 76 | } 77 | -------------------------------------------------------------------------------- /GCPadNew.ini: -------------------------------------------------------------------------------- 1 | [GCPad1] 2 | Device = Pipe/0/ctrl1 3 | Buttons/A = Button A 4 | Buttons/B = `Button B` 5 | Buttons/X = `Button X` 6 | Buttons/Y = `Button Y` 7 | Buttons/Z = `Button Z` 8 | Buttons/Start = `Button START` 9 | D-Pad/Up = `Button D_UP` 10 | D-Pad/Down = `Button D_DOWN` 11 | D-Pad/Left = `Button D_LEFT` 12 | D-Pad/Right = `Button D_RIGHT` 13 | Triggers/L = `Button L` 14 | Triggers/R = `Button R` 15 | Main Stick/Up = `Axis MAIN Y +` 16 | Main Stick/Down = `Axis MAIN Y -` 17 | Main Stick/Left = `Axis MAIN X -` 18 | Main Stick/Right = `Axis MAIN X +` 19 | C-Stick/Up = `Axis C Y +` 20 | C-Stick/Down = `Axis C Y -` 21 | C-Stick/Left = `Axis C X -` 22 | C-Stick/Right = `Axis C X +` 23 | Main Stick/Center = 0.00 0.00 24 | C-Stick/Center = 0.00 0.00 25 | [GCPad2] 26 | Device = Pipe/0/ctrl2 27 | Buttons/A = `Button A` 28 | Buttons/B = `Button B` 29 | Buttons/X = `Button X` 30 | Buttons/Y = `Button Y` 31 | Buttons/Z = `Button Z` 32 | Buttons/Start = `Button START` 33 | D-Pad/Up = `Button D_UP` 34 | D-Pad/Down = `Button D_DOWN` 35 | D-Pad/Left = `Button D_LEFT` 36 | D-Pad/Right = `Button D_RIGHT` 37 | Triggers/L = `Button L` 38 | Triggers/R = `Button R` 39 | Main Stick/Up = `Axis MAIN Y +` 40 | Main Stick/Down = `Axis MAIN Y -` 41 | Main Stick/Left = `Axis MAIN X -` 42 | Main Stick/Right = `Axis MAIN X +` 43 | C-Stick/Up = `Axis C Y +` 44 | C-Stick/Down = `Axis C Y -` 45 | C-Stick/Left = `Axis C X -` 46 | C-Stick/Right = `Axis C X +` 47 | Main Stick/Center = 0.00 0.00 48 | C-Stick/Center = 0.00 0.00 49 | [GCPad3] 50 | Device = Pipe/0/ctrl3 51 | Buttons/A = `Button A` 52 | Buttons/B = `Button B` 53 | Buttons/X = `Button X` 54 | Buttons/Y = `Button Y` 55 | Buttons/Z = `Button Z` 56 | Buttons/Start = `Button START` 57 | D-Pad/Up = `Button D_UP` 58 | D-Pad/Down = `Button D_DOWN` 59 | D-Pad/Left = `Button D_LEFT` 60 | D-Pad/Right = `Button D_RIGHT` 61 | Triggers/L = `Button L` 62 | Triggers/R = `Button R` 63 | Main Stick/Up = `Axis MAIN Y +` 64 | Main Stick/Down = `Axis MAIN Y -` 65 | Main Stick/Left = `Axis MAIN X -` 66 | Main Stick/Right = `Axis MAIN X +` 67 | C-Stick/Up = `Axis C Y +` 68 | C-Stick/Down = `Axis C Y -` 69 | C-Stick/Left = `Axis C X -` 70 | C-Stick/Right = `Axis C X +` 71 | Main Stick/Center = 0.00 0.00 72 | C-Stick/Center = 0.00 0.00 73 | [GCPad4] 74 | Device = Pipe/0/ctrl4 75 | Buttons/A = `Button A` 76 | Buttons/B = `Button B` 77 | Buttons/X = `Button X` 78 | Buttons/Y = `Button Y` 79 | Buttons/Z = `Button Z` 80 | Buttons/Start = `Button START` 81 | D-Pad/Up = `Button D_UP` 82 | D-Pad/Down = `Button D_DOWN` 83 | D-Pad/Left = `Button D_LEFT` 84 | D-Pad/Right = `Button D_RIGHT` 85 | Triggers/L = `Button L` 86 | Triggers/R = `Button R` 87 | Main Stick/Up = `Axis MAIN Y +` 88 | Main Stick/Down = `Axis MAIN Y -` 89 | Main Stick/Left = `Axis MAIN X -` 90 | Main Stick/Right = `Axis MAIN X +` 91 | C-Stick/Up = `Axis C Y +` 92 | C-Stick/Down = `Axis C Y -` 93 | C-Stick/Left = `Axis C X -` 94 | C-Stick/Right = `Axis C X +` 95 | Main Stick/Center = 0.00 0.00 96 | C-Stick/Center = 0.00 0.00 97 | -------------------------------------------------------------------------------- /WidgetExtension/WidgetExtensionLiveActivity.swift: -------------------------------------------------------------------------------- 1 | import ActivityKit 2 | import WidgetKit 3 | import SwiftUI 4 | 5 | struct WidgetExtensionLiveActivity: Widget { 6 | let aButton: some View = Button(intent: PressAIntent()) { 7 | Text("A") 8 | } 9 | .buttonStyle(GCCButton( 10 | color: GameCubeColors.green, 11 | width: 60, 12 | height: 60 13 | )) 14 | 15 | let bButton: some View = VStack { 16 | Spacer() 17 | Button(intent: PressBIntent()) { 18 | Text("B") 19 | } 20 | .buttonStyle(GCCButton( 21 | color: GameCubeColors.red, 22 | width: 50, 23 | height: 50 24 | )) 25 | }.frame(maxHeight: .infinity) 26 | 27 | var body: some WidgetConfiguration { 28 | ActivityConfiguration(for: WidgetExtensionAttributes.self) { context in 29 | // Lock screen/banner UI goes here 30 | HStack { 31 | VStack { 32 | Spacer() 33 | bButton 34 | }.frame(maxHeight: .infinity) 35 | Spacer() 36 | Text("P\(context.state.slot+1)") 37 | .gcLabel(size: 20) 38 | Spacer() 39 | aButton 40 | } 41 | .frame(maxWidth: .infinity) 42 | .padding() 43 | .activityBackgroundTint(GameCubeColors.purple) 44 | .activitySystemActionForegroundColor(Color.black) 45 | } dynamicIsland: { context in 46 | DynamicIsland { 47 | DynamicIslandExpandedRegion(.leading) { 48 | VStack { 49 | Spacer() 50 | bButton 51 | }.frame(maxHeight: .infinity) 52 | } 53 | DynamicIslandExpandedRegion(.center) { 54 | SlotText(slot: context.state.slot) 55 | .font(.gameCubeController(size: 24)).bold() 56 | } 57 | DynamicIslandExpandedRegion(.trailing) { 58 | aButton 59 | } 60 | } compactLeading: { 61 | SlotText(slot: context.state.slot) 62 | .font(.gameCubeController(size: 16)).bold() 63 | } compactTrailing: { 64 | // nothing 65 | } minimal: { 66 | SlotText(slot: context.state.slot) 67 | .font(.gameCubeController(size: 16)).bold() 68 | } 69 | .keylineTint(GameCubeColors.purple) 70 | } 71 | } 72 | } 73 | 74 | struct SlotText: View { 75 | var slot: UInt8 76 | 77 | var body: some View { 78 | Text("P\(slot+1)") 79 | } 80 | } 81 | 82 | #Preview("Notification", as: .content, using: WidgetExtensionAttributes()) { 83 | WidgetExtensionLiveActivity() 84 | } contentStates: { 85 | WidgetExtensionAttributes.ContentState(slot: 1) 86 | } 87 | -------------------------------------------------------------------------------- /macOS/DolphinControllerApp.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | @main 5 | struct DolphinControllerApp: App { 6 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate 7 | 8 | @State var showAdvancedNetworking = false 9 | 10 | var body: some Scene { 11 | WindowGroup { 12 | ZStack { 13 | GameCubeColors.purple.ignoresSafeArea() 14 | ContentView() 15 | .padding([.horizontal, .bottom]) 16 | } 17 | .toolbar { 18 | ToolbarItem(placement: ToolbarItemPlacement.automatic) { 19 | Image(systemName: "network") 20 | .accessibilityLabel("Show advanced networking information") 21 | .onTapGesture { 22 | self.showAdvancedNetworking = true 23 | } 24 | } 25 | } 26 | .foregroundColor(GameCubeColors.lightGray) 27 | .frame(idealWidth: 1, idealHeight: 1) 28 | .sheet(isPresented: self.$showAdvancedNetworking) { 29 | NetworkingInstructionsView() 30 | .padding() 31 | } 32 | .environmentObject(appDelegate.server) 33 | } 34 | .commands { 35 | CommandGroup(replacing: .newItem) {} 36 | } 37 | .windowToolbarStyle(UnifiedCompactWindowToolbarStyle()) 38 | .windowStyle(HiddenTitleBarWindowStyle()) 39 | } 40 | } 41 | 42 | class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { 43 | let server = Server() 44 | 45 | func applicationDidFinishLaunching(_ notification: Notification) { 46 | NSWindow.allowsAutomaticWindowTabbing = false 47 | 48 | signal(SIGPIPE) { _ in 49 | // ignore sigpipes 50 | } 51 | 52 | try! server.start() 53 | 54 | do { 55 | let applicationSupport = try FileManager.default.url( 56 | for: .applicationSupportDirectory, 57 | in: .userDomainMask, 58 | appropriateFor: nil, 59 | create: true 60 | ) 61 | 62 | let actualConfigUrl = applicationSupport 63 | .appendingPathComponent("Dolphin") 64 | .appendingPathComponent("Config") 65 | .appendingPathComponent("GCPadNew.ini") 66 | if let requiredConfigUrl = Bundle.main.url(forResource: "GCPadNew", withExtension: "ini") { 67 | if !FileManager.default.contentsEqual(atPath: requiredConfigUrl.path, andPath: actualConfigUrl.path) { 68 | if FileManager.default.isWritableFile(atPath: actualConfigUrl.path) { 69 | try FileManager.default.removeItem(at: actualConfigUrl) 70 | try FileManager.default.copyItem(at: requiredConfigUrl, to: actualConfigUrl) 71 | } 72 | } 73 | } 74 | } catch { 75 | print("Error setting up config", error) 76 | } 77 | } 78 | 79 | func applicationWillTerminate(_ notification: Notification) { 80 | try! server.stop() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dolphin Controller 2 | 3 | Video games were meant to be played together. All you need to play a game with friends on Dolphin is your laptop and this app, over bluetooth or Wi-Fi. 4 | 5 | | macOS Server | iOS Client | iOS Server Browser | 6 | | ------------ | ---------- | ------------------ | 7 | | Server UI | ![Client UI](https://user-images.githubusercontent.com/329222/131947045-28fb3a63-58fe-47e7-a7b8-e3f4a365dee7.png) | ![Server Join UI](https://user-images.githubusercontent.com/329222/131947834-1a5de0b6-9a95-46bd-95a4-b4afc0aa7ccc.PNG) | 8 | 9 | ## Installation & Usage 10 | 11 | 1. [Download the latest version of Dolphin emulator](https://dolphin-emu.org) 12 | 2. [Install the iOS app from the App Store](https://apps.apple.com/us/app/dolphin-ctrl/id1584272645) and the [macOS server from GitHub](https://github.com/apexskier/dolphin-controller/releases/latest) (or build and run with XCode) 13 | 3. From the iOS app, tap "Join" and find your server 14 | 4. Pick a controller number by tapping P1, P2, P3, or P4 15 | 16 | **Important**: 17 | 18 | Dolphin Controller can be _slow_ depending the speed of your WiFi/bluetooth/remote network connect _and_ the speed of the Dolphin emulator itself. Try verifying the performance with a simple game if a more performance intensive game feels slow. Tips for improving performance can be found in Dolphin's [Performance Guide](https://dolphin-emu.org/docs/guides/performance-guide/). 19 | 20 | ## Setup 21 | 22 | In order for the app to interact with the Dolphin Emulator software, this app takes advantage of [Dolphin's pipe input feature](https://wiki.dolphin-emu.org/index.php?title=Pipe_Input). 23 | 24 | The server will automatically write the correct config and create the required FIFO pipes. 25 | 26 | From the Dolphin app, open the controller settings (Options > Controller Settings in the menu bar). For each controller you wish to connect in-game, change "Port N" to "Standard Controller". 27 | 28 | ![Dolphin Controller Settings](https://user-images.githubusercontent.com/329222/130376541-ca943da6-963d-4706-b2a0-74b6e4516f1c.png) 29 | 30 | ### Verification 31 | 32 | You can verify the controller is connected by clicking "Configure" and ensuring "Device" is connected to "Pipe/0/ctrlN". From the configure window, you can also verify that the UI responds to interactions on your iOS device. 33 | 34 | ![Controller configuration verification](https://user-images.githubusercontent.com/329222/130376738-b08f01c5-7360-4f17-909e-abcddf0c3264.png) 35 | 36 | ## Tips 37 | 38 | * The iOS app will attempt to auto-reconnect with the same controller number if it looses a connection (if your screen locks or the app backgrounds). 39 | * In the server browser window (after tapping "Join") the ★'d server is the one last connected to. 40 | * Servers are advertised automatically with Bonjour, so no need to enter manual information if everyone's in the same room. 41 | * For remote play, tap the Network icon in the macOS app's toolbar to find the port, forward to a public IP address, and enter the address manually. 42 | * The server is not currently in the App Store because it breaks sandboxing by modifying Dolphin's configuration files directly. A long term goal is to figure out a way to avoid that to distribute on the App Store along side the iOS app. 43 | 44 | ❤️ inspired by (and originally forked from, but since rewritten) https://github.com/ajaymerchia/dolphin-controller 45 | -------------------------------------------------------------------------------- /macOS/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | import Combine 4 | 5 | let dotVerticalSpace: CGFloat = 16 6 | 7 | struct FaceplateShape: Shape { 8 | func path(in rect: CGRect) -> Path { 9 | Path { path in 10 | let width = rect.width 11 | let height = rect.height 12 | 13 | let inset: CGFloat = width * 0.01 14 | 15 | path.move(to: CGPoint(x: inset, y: 0)) 16 | path.addLine(to: CGPoint(x: width - inset, y: 0)) 17 | path.addQuadCurve( 18 | to: CGPoint(x: width - inset, y: height), 19 | control: CGPoint(x: width + inset, y: height / 2) 20 | ) 21 | path.addLine(to: CGPoint(x: inset, y: height)) 22 | path.addQuadCurve( 23 | to: CGPoint(x: inset, y: 0), 24 | control: CGPoint(x: -inset, y: height / 2) 25 | ) 26 | 27 | path.closeSubpath() 28 | } 29 | } 30 | } 31 | 32 | struct ContentView: View { 33 | @EnvironmentObject private var server: Server 34 | @State private var error: Error? = nil 35 | 36 | var connectedControllerCount: Int { 37 | server.controllers 38 | .compactMap({ $0.value }) 39 | .count 40 | } 41 | 42 | var body: some View { 43 | VStack { 44 | Spacer(minLength: 0) 45 | Text("Connect to\n“\(server.name)”") 46 | .multilineTextAlignment(.center) 47 | .fixedSize() 48 | Spacer(minLength: 16) 49 | HStack(spacing: 30) { 50 | ForEach(0.. 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Shared/Networking.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | import CryptoKit 4 | 5 | let serviceType = "dolphinC" 6 | 7 | extension NWParameters { 8 | static func custom() -> NWParameters { 9 | // Customize TCP options to enable keepalives. 10 | let tcpOptions = NWProtocolTCP.Options() 11 | tcpOptions.enableKeepalive = true 12 | tcpOptions.keepaliveIdle = 2 13 | 14 | // Create parameters with custom TLS and TCP options. 15 | let params = NWParameters(tls: NWParameters.tlsOptions(passcode: "passcode"), tcp: tcpOptions) 16 | 17 | // Enable using a peer-to-peer link. 18 | params.includePeerToPeer = true 19 | params.acceptLocalOnly = false 20 | params.serviceClass = .interactiveVideo // not really, but I want this fast, and it's low size 21 | 22 | // Add your custom game protocol to support game messages. 23 | let controllerProtocolOptions = NWProtocolFramer.Options(definition: ControllerProtocol.definition) 24 | params.defaultProtocolStack.applicationProtocols.insert(controllerProtocolOptions, at: 0) 25 | 26 | return params 27 | } 28 | 29 | // Create TLS options using a passcode to derive a pre-shared key. 30 | private static func tlsOptions(passcode: String) -> NWProtocolTLS.Options { 31 | let tlsOptions = NWProtocolTLS.Options() 32 | 33 | let authenticationKey = SymmetricKey(data: passcode.data(using: .utf8)!) 34 | var authenticationCode = HMAC.authenticationCode( 35 | for: "DolphinController".data(using: .utf8)!, 36 | using: authenticationKey 37 | ) 38 | 39 | let authenticationDispatchData = withUnsafeBytes(of: &authenticationCode) { ptr in 40 | DispatchData(bytes: ptr) 41 | } 42 | 43 | sec_protocol_options_add_pre_shared_key( 44 | tlsOptions.securityProtocolOptions, 45 | authenticationDispatchData as __DispatchData, 46 | stringToDispatchData("DolphinController")! as __DispatchData 47 | ) 48 | sec_protocol_options_append_tls_ciphersuite( 49 | tlsOptions.securityProtocolOptions, 50 | tls_ciphersuite_t(rawValue: UInt16(TLS_PSK_WITH_AES_128_GCM_SHA256))! 51 | ) 52 | return tlsOptions 53 | } 54 | 55 | // Utility function to encode strings as pre-shared key data. 56 | private static func stringToDispatchData(_ string: String) -> DispatchData? { 57 | guard let stringData = string.data(using: .unicode) else { 58 | return nil 59 | } 60 | let dispatchData = withUnsafeBytes(of: stringData) { ptr in 61 | DispatchData(bytes: UnsafeRawBufferPointer(start: ptr.baseAddress, count: stringData.count)) 62 | } 63 | return dispatchData 64 | } 65 | } 66 | 67 | extension NWConnection { 68 | func sendMessage(_ type: ControllerMessageType, data: Data) { 69 | let message = NWProtocolFramer.Message(controllerMessageType: type) 70 | let context = NWConnection.ContentContext( 71 | identifier: type.debugDescription, 72 | metadata: [message] 73 | ) 74 | 75 | self.send( 76 | content: data, 77 | contentContext: context, 78 | isComplete: true, 79 | completion: .idempotent 80 | ) 81 | } 82 | 83 | func handleReceiveError(error: NWError) { 84 | var shouldCancel = true 85 | if case .posix(let code) = error { 86 | switch code { 87 | case .ENODATA: 88 | print("Disconnected (no data)") 89 | case .ECONNABORTED: 90 | print("Connection aborted") 91 | case .ECANCELED: 92 | shouldCancel = false 93 | print("Connection cancelled") 94 | default: 95 | print("Posix error", error) 96 | } 97 | } else { 98 | print("Error", error) 99 | } 100 | if shouldCancel { 101 | // we explicitly cancel even if the connection has been aborted 102 | // without this, the connection takes a bit to transition to its 103 | // cancelled state, which is not a great UX 104 | self.cancel() 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iPhone_Notifications_20_2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "iPhone_Notifications_20_3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "iPhone_Settings_29_2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "iPhone_Settings_29_3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "iPhone_Spotlight_40_2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "iPhone_Spotlight_40_3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "iPhone_App_60_2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "iPhone_App_60_3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "iPad_Notifications_20_1x.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "iPad_Notifications_20_2x.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "iPad_Settings_29_1x.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "iPad_Settings_29_2x.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "iPad_Spotlight_40_1x.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "iPad_Spotlight_40_2x.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "iPad_App_76_1x.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "iPad_App_76_2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "iPad_Pro_App_83.5_2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "App_store_1024_1x.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | }, 111 | { 112 | "filename" : "16.png", 113 | "idiom" : "mac", 114 | "scale" : "1x", 115 | "size" : "16x16" 116 | }, 117 | { 118 | "filename" : "16@2x.png", 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "16x16" 122 | }, 123 | { 124 | "filename" : "32.png", 125 | "idiom" : "mac", 126 | "scale" : "1x", 127 | "size" : "32x32" 128 | }, 129 | { 130 | "filename" : "32@2x.png", 131 | "idiom" : "mac", 132 | "scale" : "2x", 133 | "size" : "32x32" 134 | }, 135 | { 136 | "filename" : "128.png", 137 | "idiom" : "mac", 138 | "scale" : "1x", 139 | "size" : "128x128" 140 | }, 141 | { 142 | "filename" : "128@2x.png", 143 | "idiom" : "mac", 144 | "scale" : "2x", 145 | "size" : "128x128" 146 | }, 147 | { 148 | "filename" : "256.png", 149 | "idiom" : "mac", 150 | "scale" : "1x", 151 | "size" : "256x256" 152 | }, 153 | { 154 | "filename" : "256@2x.png", 155 | "idiom" : "mac", 156 | "scale" : "2x", 157 | "size" : "256x256" 158 | }, 159 | { 160 | "filename" : "512.png", 161 | "idiom" : "mac", 162 | "scale" : "1x", 163 | "size" : "512x512" 164 | }, 165 | { 166 | "filename" : "512@2x.png", 167 | "idiom" : "mac", 168 | "scale" : "2x", 169 | "size" : "512x512" 170 | } 171 | ], 172 | "info" : { 173 | "author" : "xcode", 174 | "version" : 1 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /macOS/ControllerConnection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | import Combine 4 | 5 | let pingInterval: TimeInterval = 1 6 | let maxPingCount = 5 7 | 8 | final class ControllerConnection: Identifiable { 9 | internal var id = UUID() // this allows using this in more swiftui places 10 | 11 | let connection: NWConnection 12 | private let didClose: (Error?) -> Void 13 | private let connectionReady: () -> Void 14 | private let didPickControllerNumber: (UInt8) -> Void 15 | 16 | let errorPublisher = PassthroughSubject() 17 | 18 | private var pipe: ControllerFilePipe? = nil 19 | 20 | init( 21 | connection: NWConnection, 22 | didClose: @escaping (Error?) -> Void, 23 | connectionReady: @escaping () -> Void, 24 | didPickControllerIndex: @escaping (UInt8) -> Void 25 | ) throws { 26 | self.connection = connection 27 | self.didClose = didClose 28 | self.connectionReady = connectionReady 29 | self.didPickControllerNumber = didPickControllerIndex 30 | 31 | connection.stateUpdateHandler = self.handleStateUpdate 32 | connection.start(queue: .global(qos: .userInitiated)) 33 | } 34 | 35 | func disconnect() { 36 | if connection.state != .cancelled { 37 | connection.cancel() 38 | } 39 | } 40 | 41 | private func handleStateUpdate(state: NWConnection.State) { 42 | switch state { 43 | case .ready: 44 | self.connectionReady() 45 | receiveNextMessage() 46 | case .cancelled: 47 | didClose(nil) 48 | case .failed(let error): 49 | didClose(error) 50 | default: 51 | break 52 | } 53 | } 54 | 55 | private func receiveNextMessage() { 56 | if connection.state != .ready { 57 | print("connection not ready, stopping receiving") 58 | return 59 | } 60 | 61 | connection.receiveMessage { (content, context, isComplete, error) in 62 | if let error = error { 63 | self.connection.handleReceiveError(error: error) 64 | return 65 | } 66 | 67 | // Extract your message type from the received context. 68 | if let message = context?.protocolMetadata(definition: ControllerProtocol.definition) as? NWProtocolFramer.Message { 69 | guard let content = content else { 70 | fatalError("missing content in \(message.controllerMessageType)") 71 | } 72 | switch message.controllerMessageType { 73 | case .command: 74 | guard let pipe = self.pipe else { 75 | // controller number hasn't been chosen 76 | return 77 | } 78 | do { 79 | try pipe.streamText(data: content) 80 | } catch { 81 | self.errorPublisher.send(error) 82 | } 83 | case .pickController: 84 | let controllerNumber = content.withUnsafeBytes { pointer in 85 | pointer.load(as: UInt8.self) 86 | } 87 | 88 | do { 89 | self.pipe = try ControllerFilePipe(index: controllerNumber) 90 | self.didPickControllerNumber(controllerNumber) 91 | } catch { 92 | self.errorPublisher.send(error) 93 | } 94 | case .ping: 95 | self.connection.sendMessage(.pong, data: content) 96 | case .errorMessage: 97 | let errorStr = String(data: content, encoding: .utf8) ?? "Unknown error" 98 | self.errorPublisher.send(ControllerProtocol.ProtocolError.errorMessage(errorStr)) 99 | case .pong, .controllerInfo: 100 | fatalError("unexpected \(message.controllerMessageType) in server") 101 | } 102 | } 103 | 104 | // recurse to continue receiving messages 105 | self.receiveNextMessage() 106 | } 107 | } 108 | } 109 | 110 | extension ControllerConnection: Hashable { 111 | func hash(into: inout Hasher) { 112 | into.combine(id) 113 | } 114 | 115 | static func == (lhs: ControllerConnection, rhs: ControllerConnection) -> Bool { 116 | lhs.id == rhs.id 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /iOS/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | struct ContentView: View { 5 | @EnvironmentObject private var client: Client 6 | @Binding var shouldAutoReconnect: Bool 7 | @State private var hostCode: String = "" 8 | @State private var error: Error? = nil 9 | @State private var errorStr: String? = nil 10 | @State private var clientConnectionCancellable: AnyCancellable? = nil 11 | @State private var clientDisconnectionCancellable: AnyCancellable? = nil 12 | @State private var choosingConnection = false 13 | @State private var showingSettings = false 14 | @State private var ping: TimeInterval? = nil 15 | @AppStorage("showPing") private var showPing = false 16 | 17 | var body: some View { 18 | ZStack { 19 | if showPing { 20 | HStack(alignment: .top) { 21 | VStack(alignment: .leading) { 22 | PingView(ping: self.ping) 23 | Spacer() 24 | } 25 | Spacer() 26 | } 27 | .onReceive(client.pingPublisher, perform: { duration in 28 | self.ping = duration 29 | }) 30 | } 31 | ControllerView( 32 | playerIndicators: HStack(alignment: .center, spacing: 0) { 33 | ForEach(0..: View where Label: View { 16 | @EnvironmentObject var client: Client 17 | 18 | var identifier: String 19 | var color: Color 20 | var diameter: CGFloat 21 | var knobDiameter: CGFloat 22 | var label: Label 23 | 24 | private let pressHaptic = UIImpactFeedbackGenerator(style: .rigid) 25 | @State private var hapticContainer: HapticContainer 26 | 27 | @AppStorage("joystickHapticsEnabled") private var joystickHapticsEnabled = true 28 | 29 | init( 30 | identifier: String, 31 | color: Color, 32 | diameter: CGFloat, 33 | knobDiameter: CGFloat, 34 | label: Label, 35 | hapticsSharpness: Float 36 | ) { 37 | self.identifier = identifier 38 | self.color = color 39 | self.diameter = diameter 40 | self.knobDiameter = knobDiameter 41 | self.label = label 42 | self._hapticContainer = .init(initialValue: HapticContainer(sharpness: hapticsSharpness)) 43 | } 44 | 45 | @State private var inCenter: Bool = true { 46 | willSet { 47 | if inCenter != newValue { 48 | if newValue { 49 | pressHaptic.impactOccurred(intensity: 0.6) 50 | } else { 51 | pressHaptic.impactOccurred(intensity: 0.3) 52 | } 53 | } 54 | } 55 | } 56 | @State private var outsideEdges: Bool = false { 57 | willSet { 58 | if newValue { 59 | if !outsideEdges { 60 | pressHaptic.impactOccurred(intensity: 0.8) 61 | } 62 | } 63 | } 64 | } 65 | @State private var dragValue: DragGesture.Value? = nil { 66 | willSet { 67 | guard let value = newValue else { 68 | client.send("SET \(identifier) 0.5 0.5") 69 | inCenter = true 70 | hapticContainer.haptics.stop() 71 | return 72 | } 73 | 74 | if dragValue == nil { 75 | hapticContainer.haptics.start() 76 | pressHaptic.impactOccurred() 77 | } 78 | 79 | let translation = value.translation 80 | let x = (translation.width / (diameter*1.5)).clamped(to: -0.5...0.5) 81 | let y = (-translation.height / (diameter*1.5)).clamped(to: -0.5...0.5) 82 | client.send("SET \(identifier) \(x+0.5) \(y+0.5)") 83 | let magnitude = sqrt(x*2*x*2 + y*2*y*2) 84 | inCenter = magnitude < 0.2 85 | outsideEdges = magnitude > 1 86 | 87 | hapticContainer.haptics.setIntensity(magnitude) 88 | } 89 | } 90 | 91 | private var knob: some View { 92 | ZStack { 93 | Circle() 94 | .fill(color) 95 | .frame(width: knobDiameter, height: knobDiameter) 96 | .shadow(color: Color.black.opacity(0.3), radius: 2, x: 0, y: 1) 97 | label 98 | } 99 | .allowsHitTesting(false) 100 | } 101 | 102 | private var transparentColor: Color { 103 | color.opacity(0.2) 104 | } 105 | 106 | private var target: some View { 107 | Polygon(corners: 8) 108 | .fill(transparentColor) 109 | .frame(width: diameter, height: diameter) 110 | } 111 | 112 | var body: some View { 113 | ZStack(alignment: .center) { 114 | target 115 | .gesture( 116 | DragGesture(minimumDistance: 0) 117 | .onChanged({ value in 118 | self.dragValue = value 119 | self.pressHaptic.prepare() 120 | }) 121 | .onEnded({ value in 122 | self.dragValue = nil 123 | }) 124 | ) 125 | ZStack { 126 | if let dragValue = self.dragValue { 127 | ZStack { 128 | Circle() 129 | .fill(transparentColor) 130 | .frame(width: diameter*1.3, height: diameter*1.3) 131 | } 132 | .position( 133 | x: dragValue.startLocation.x, 134 | y: dragValue.startLocation.y 135 | ) 136 | draggedKnob(drag: dragValue) 137 | } else { 138 | knob 139 | } 140 | } 141 | .allowsHitTesting(false) 142 | } 143 | .frame(width: diameter, height: diameter) // ensure layout shifting won't happen when dragging 144 | .onAppear(perform: { 145 | self.hapticContainer.haptics.enabled = self.joystickHapticsEnabled 146 | }) 147 | .onChange(of: joystickHapticsEnabled) { newValue in 148 | self.hapticContainer.haptics.enabled = newValue 149 | } 150 | } 151 | 152 | private func draggedKnob(drag: DragGesture.Value) -> some View { 153 | // limit position of knob to outer edges of valid position 154 | var x = drag.translation.width 155 | var y = drag.translation.height 156 | let magnitude = sqrt(x*x + y*y) 157 | let r = (diameter*1.5)/2 158 | if magnitude > r { 159 | x = (x / magnitude) * r 160 | y = (y / magnitude) * r 161 | } 162 | return knob 163 | .position( 164 | x: drag.startLocation.x + x, 165 | y: drag.startLocation.y + y 166 | ) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /iOS/Views/ServerBrowserView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Network 3 | import SwiftUI 4 | 5 | struct ServerBrowserView: View { 6 | @Environment(\.presentationMode) private var presentationMode 7 | @EnvironmentObject private var client: Client 8 | @ObservedObject private var serverBrowser = ServerBrowser() 9 | @State private var choosingManualConnection = false 10 | @AppStorage(StorageKeys.lastManualAddress.rawValue) private var manualServer = "" 11 | @State private var validatedServerParts: (host: String, portInt: UInt16)? = nil 12 | 13 | var didConnect: (NWEndpoint) -> Void 14 | 15 | private func lastServerIndicator(server: NWEndpoint) -> Text { 16 | Text(client.lastServer == server ? "\(Image(systemName: "star.fill")) " : "") 17 | } 18 | 19 | var body: some View { 20 | NavigationView { 21 | VStack { 22 | List { 23 | Section(header: Text("Local \(Image(systemName: "bonjour"))")) { 24 | if !serverBrowser.servers.isEmpty { 25 | ForEach(serverBrowser.servers) { server in 26 | Button("\(self.lastServerIndicator(server: server.endpoint))\(server.name)") { 27 | self.didConnect(server.endpoint) 28 | self.presentationMode.wrappedValue.dismiss() 29 | } 30 | } 31 | } else if (serverBrowser.loading) { 32 | ProgressView("Browsing").frame(maxWidth: .infinity) 33 | } 34 | } 35 | Section(header: Text("Manual \(Image(systemName: "network"))")) { 36 | HStack { 37 | TextField("192.168.0.123:12345", text: $manualServer) 38 | .disableAutocorrection(true) 39 | .accessibilityLabel("Manually entered server address") 40 | .textFieldStyle(RoundedBorderTextFieldStyle()) 41 | Button("Connect") { 42 | guard let (host, portInt) = self.validatedServerParts else { 43 | return 44 | } 45 | self.didConnect( 46 | NWEndpoint.hostPort( 47 | host: .init(host), 48 | port: NWEndpoint.Port(integerLiteral: portInt) 49 | ) 50 | ) 51 | self.presentationMode.wrappedValue.dismiss() 52 | } 53 | .disabled(validatedServerParts == nil) 54 | .onChange(of: manualServer, perform: self.validateManualServer) 55 | .onAppear { 56 | self.validateManualServer(newManualServer: manualServer) 57 | } 58 | } 59 | } 60 | HelpView() 61 | } 62 | } 63 | .navigationBarTitle("Server Browser") 64 | .navigationBarItems(trailing: Button("Close", action: { 65 | self.presentationMode.wrappedValue.dismiss() 66 | })) 67 | } 68 | .onAppear { 69 | serverBrowser.start() 70 | } 71 | .onDisappear { 72 | serverBrowser.stop() 73 | } 74 | } 75 | 76 | func validateManualServer(newManualServer: String) { 77 | let parts = newManualServer.split(separator: ":") 78 | if parts.count != 2 { 79 | self.validatedServerParts = nil 80 | return 81 | } 82 | let host = String(parts[0]) 83 | guard let portInt = UInt16(parts[1]) else { 84 | self.validatedServerParts = nil 85 | return 86 | } 87 | self.validatedServerParts = (host, portInt) 88 | } 89 | } 90 | 91 | #Preview { 92 | ServerBrowserView { connection in 93 | print(connection) 94 | } 95 | } 96 | 97 | private class Server: NSObject, ObservableObject, Identifiable { 98 | var id: ObjectIdentifier { 99 | ObjectIdentifier("\(name)" as NSString) 100 | } 101 | 102 | let name: String 103 | let endpoint: NWEndpoint 104 | 105 | init(name: String, endpoint: NWEndpoint) { 106 | self.name = name 107 | self.endpoint = endpoint 108 | 109 | super.init() 110 | } 111 | } 112 | 113 | private class ServerBrowser: NSObject, ObservableObject { 114 | private let browser: NWBrowser 115 | 116 | @Published 117 | var loading: Bool = false 118 | 119 | @Published 120 | fileprivate var servers = [Server]() { 121 | willSet { 122 | self.serversSinks = newValue.map({ value in 123 | value.forwardChanges(to: self) 124 | }) 125 | } 126 | } 127 | private var serversSinks = [AnyCancellable]() 128 | 129 | override init() { 130 | let parameters = NWParameters() 131 | parameters.includePeerToPeer = true 132 | self.browser = NWBrowser( 133 | for: .bonjour(type: "_\(serviceType)._tcp.", domain: nil), 134 | using: parameters 135 | ) 136 | 137 | super.init() 138 | 139 | browser.stateUpdateHandler = { state in 140 | DispatchQueue.main.async { 141 | switch state { 142 | case .ready: 143 | self.loading = true 144 | default: 145 | self.loading = false 146 | } 147 | } 148 | } 149 | browser.browseResultsChangedHandler = { services, change in 150 | let servers: [Server] = services.compactMap({ service in 151 | switch service.endpoint { 152 | case .service(name: let name, type: _, domain: _, interface: _): 153 | return Server(name: name, endpoint: service.endpoint) 154 | default: 155 | // ignore, we're only looking for bonjour services 156 | return nil 157 | } 158 | }) 159 | DispatchQueue.main.async { 160 | self.servers = servers 161 | } 162 | } 163 | } 164 | 165 | deinit { 166 | stop() 167 | } 168 | 169 | func start() { 170 | browser.start(queue: .global(qos: .userInitiated)) 171 | } 172 | 173 | func stop() { 174 | browser.cancel() 175 | loading = false 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /macOS/Server.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import Network 4 | 5 | public class Server: ObservableObject { 6 | private let netService: NWListener 7 | 8 | let name = "\(Host.current().localizedName ?? Host.current().name ?? "Unknown computer") - Dolphin Controller Server" 9 | 10 | @Published var broadcasting: Bool = false 11 | @Published var controllers: [UInt8: ControllerConnection?] = [:] 12 | 13 | @Published var port: NWEndpoint.Port? = nil 14 | private var allControllers: [ControllerConnection] = [] 15 | 16 | init() { 17 | self.netService = try! NWListener(using: .custom()) 18 | netService.service = NWListener.Service( 19 | name: self.name, 20 | type: "_\(serviceType)._tcp.", 21 | domain: nil, 22 | txtRecord: nil 23 | ) 24 | netService.stateUpdateHandler = { state in 25 | DispatchQueue.main.async { 26 | self.port = self.netService.port 27 | switch state { 28 | case .ready: 29 | self.broadcasting = true 30 | default: 31 | self.broadcasting = false 32 | } 33 | } 34 | } 35 | netService.serviceRegistrationUpdateHandler = { change in 36 | DispatchQueue.main.async { 37 | self.port = self.netService.port 38 | } 39 | } 40 | netService.newConnectionHandler = { connection in 41 | var index: UInt8? = nil 42 | 43 | var controllerConnection: ControllerConnection? = nil 44 | controllerConnection = try! ControllerConnection( 45 | connection: connection, 46 | didClose: { error in 47 | DispatchQueue.main.async { 48 | if let controllerConnection = controllerConnection, 49 | let index = self.allControllers.firstIndex(of: controllerConnection) { 50 | self.allControllers.remove(at: index) 51 | } 52 | if let i = index { 53 | self.controllers[i] = nil 54 | } 55 | index = nil 56 | self.sendControllerInfo() 57 | } 58 | }, 59 | connectionReady: { 60 | self.sendControllerInfo() 61 | }, 62 | didPickControllerIndex: { newIndex in 63 | DispatchQueue.main.async { 64 | if newIndex != index && self.controllers[newIndex] != nil { 65 | self.sendError(error: "That controller is already taken.", to: connection) 66 | return 67 | } 68 | if let i = index { 69 | self.controllers[i] = nil 70 | } 71 | index = newIndex 72 | self.controllers[newIndex] = controllerConnection 73 | self.sendControllerInfo() 74 | } 75 | } 76 | ) 77 | 78 | DispatchQueue.main.async { 79 | if let controllerConnection = controllerConnection { 80 | self.allControllers.append(controllerConnection) 81 | } 82 | } 83 | } 84 | } 85 | 86 | private func getAvailableControllers() -> AvailableControllers { 87 | var availableControllers = AvailableControllers() 88 | for i in 0...size), 112 | contentContext: context, 113 | isComplete: true, 114 | completion: .idempotent 115 | ) 116 | } 117 | } 118 | } 119 | } 120 | 121 | private func sendError(error: String, to connection: NWConnection) { 122 | guard connection.state == .ready else { 123 | print("Connection not ready to send error on") 124 | return 125 | } 126 | guard let data = error.data(using: .utf8) else { 127 | print("Failed to utf8 encode error string") 128 | return 129 | } 130 | connection.sendMessage(.errorMessage, data: data) 131 | } 132 | 133 | func start() throws { 134 | netService.start(queue: .global(qos: .userInitiated)) 135 | } 136 | 137 | func stop() throws { 138 | netService.cancel() 139 | print("Server closed") 140 | } 141 | } 142 | 143 | enum PipeError: Error { 144 | case openFailed 145 | } 146 | 147 | func createPipe(index: UInt8) throws -> OutputStream { 148 | let applicationSupport = try FileManager.default.url( 149 | for: .applicationSupportDirectory, 150 | in: .userDomainMask, 151 | appropriateFor: nil, 152 | create: true 153 | ) 154 | 155 | let pipesFolder = applicationSupport 156 | .appendingPathComponent("Dolphin") 157 | .appendingPathComponent("Pipes") 158 | if !FileManager.default.fileExists(atPath: pipesFolder.path) { 159 | try FileManager.default.createDirectory( 160 | at: pipesFolder, 161 | withIntermediateDirectories: true, 162 | attributes: nil 163 | ) 164 | } 165 | let pipeUrl = pipesFolder.appendingPathComponent("ctrl\(index+1)") 166 | mkfifo(pipeUrl.path, 0o644) 167 | guard let outputStream = OutputStream(url: pipeUrl, append: true) else { 168 | throw PipeError.openFailed 169 | } 170 | 171 | return outputStream 172 | } 173 | -------------------------------------------------------------------------------- /Shared/ControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Network 3 | 4 | // Don't change the explicit values assigned to these, in order to avoid 5 | // accidental backwards incompatibility if order is changed in future 6 | enum ControllerMessageType: UInt32, CustomDebugStringConvertible { 7 | case errorMessage = 0 8 | 9 | // sent from client to server 10 | // data: a string, to be passed to dolphin's pipe as a command (doesn't include newline) 11 | case command = 1 12 | 13 | // sent from server to client 14 | // send the available controller numbers 15 | // data: bitmask of available controller 16 | case controllerInfo = 2 17 | 18 | // sent from client to server 19 | // request a specific controller number 20 | // data: a single UInt8 21 | case pickController = 3 22 | 23 | // sent from either to other 24 | // data: UUID 25 | case ping = 4 26 | 27 | // sent from other to either in response to a ping 28 | // data: UUID (from ping) 29 | case pong = 5 30 | 31 | var debugDescription: String { 32 | switch self { 33 | case .errorMessage: 34 | return "errorMessage" 35 | case .command: 36 | return "command" 37 | case .controllerInfo: 38 | return "controllerInfo" 39 | case .pickController: 40 | return "pickController" 41 | case .ping: 42 | return "ping" 43 | case .pong: 44 | return "pong" 45 | } 46 | } 47 | } 48 | 49 | struct ClientControllerInfo { 50 | let availableControllers: AvailableControllers 51 | let assignedController: UInt8? 52 | } 53 | 54 | struct AvailableControllers: OptionSet { 55 | let rawValue: UInt8 56 | 57 | static let numberOfControllers: UInt8 = 4 58 | static let range = 0.. Self { 61 | guard range.contains(index) else { 62 | fatalError("Index of out range for available controllers") 63 | } 64 | 65 | return AvailableControllers(rawValue: 1 << index) 66 | } 67 | } 68 | 69 | // Create a class that implements a framing protocol. 70 | class ControllerProtocol: NWProtocolFramerImplementation { 71 | // Create a global definition of your game protocol to add to connections. 72 | static let definition = NWProtocolFramer.Definition(implementation: ControllerProtocol.self) 73 | 74 | static var label: String { 75 | return "Controller" 76 | } 77 | 78 | // Set the default behavior for most framing protocol functions. 79 | required init(framer: NWProtocolFramer.Instance) { } 80 | func start(framer: NWProtocolFramer.Instance) -> NWProtocolFramer.StartResult { return .ready } 81 | func wakeup(framer: NWProtocolFramer.Instance) { } 82 | func stop(framer: NWProtocolFramer.Instance) -> Bool { return true } 83 | func cleanup(framer: NWProtocolFramer.Instance) { } 84 | 85 | // Whenever the application sends a message, add your protocol header and forward the bytes. 86 | func handleOutput(framer: NWProtocolFramer.Instance, message: NWProtocolFramer.Message, messageLength: Int, isComplete: Bool) { 87 | // Extract the type of message. 88 | let type = message.controllerMessageType 89 | 90 | // Create a header using the type and length. 91 | let header = ControllerProtocolHeader(type: type.rawValue, length: UInt32(messageLength)) 92 | 93 | // Write the header. 94 | framer.writeOutput(data: header.encodedData) 95 | 96 | // Ask the connection to insert the content of the application message after your header. 97 | do { 98 | try framer.writeOutputNoCopy(length: messageLength) 99 | } catch let error { 100 | print("Error writing \(error)") 101 | } 102 | } 103 | 104 | // Whenever new bytes are available to read, try to parse out your message format. 105 | func handleInput(framer: NWProtocolFramer.Instance) -> Int { 106 | while true { 107 | // Try to read out a single header. 108 | var tempHeader: ControllerProtocolHeader? = nil 109 | let headerSize = ControllerProtocolHeader.encodedSize 110 | let parsed = framer.parseInput( 111 | minimumIncompleteLength: headerSize, 112 | maximumLength: headerSize 113 | ) { buffer, isComplete in 114 | guard let buffer = buffer else { 115 | return 0 116 | } 117 | if buffer.count < headerSize { 118 | return 0 119 | } 120 | tempHeader = ControllerProtocolHeader(buffer) 121 | return headerSize 122 | } 123 | 124 | // If you can't parse out a complete header, stop parsing and ask for headerSize more bytes. 125 | guard parsed, let header = tempHeader else { 126 | return headerSize 127 | } 128 | 129 | // Create an object to deliver the message. 130 | var messageType = ControllerMessageType.errorMessage 131 | if let parsedMessageType = ControllerMessageType(rawValue: header.type) { 132 | messageType = parsedMessageType 133 | } 134 | let message = NWProtocolFramer.Message(controllerMessageType: messageType) 135 | 136 | // Deliver the body of the message, along with the message object. 137 | if !framer.deliverInputNoCopy(length: Int(header.length), message: message, isComplete: true) { 138 | return 0 139 | } 140 | } 141 | } 142 | 143 | enum ProtocolError: LocalizedError { 144 | case errorMessage(String) 145 | 146 | public var errorDescription: String? { 147 | switch self { 148 | case .errorMessage(let str): 149 | return str 150 | } 151 | } 152 | } 153 | } 154 | 155 | // Extend framer messages to handle storing your command types in the message metadata. 156 | extension NWProtocolFramer.Message { 157 | convenience init(controllerMessageType: ControllerMessageType) { 158 | self.init(definition: ControllerProtocol.definition) 159 | self["ControllerMessageType"] = controllerMessageType 160 | } 161 | 162 | var controllerMessageType: ControllerMessageType { 163 | if let type = self["ControllerMessageType"] as? ControllerMessageType { 164 | return type 165 | } else { 166 | return .errorMessage 167 | } 168 | } 169 | } 170 | 171 | // Define a protocol header struct to help encode and decode bytes. 172 | struct ControllerProtocolHeader: Codable { 173 | let type: UInt32 174 | let length: UInt32 175 | 176 | init(type: UInt32, length: UInt32) { 177 | self.type = type 178 | self.length = length 179 | } 180 | 181 | init(_ buffer: UnsafeMutableRawBufferPointer) { 182 | var tempType: UInt32 = 0 183 | var tempLength: UInt32 = 0 184 | withUnsafeMutableBytes(of: &tempType) { typePtr in 185 | typePtr.copyMemory( 186 | from: UnsafeRawBufferPointer( 187 | start: buffer.baseAddress!.advanced(by: 0), 188 | count: MemoryLayout.size 189 | ) 190 | ) 191 | } 192 | withUnsafeMutableBytes(of: &tempLength) { lengthPtr in 193 | lengthPtr.copyMemory( 194 | from: UnsafeRawBufferPointer( 195 | start: buffer.baseAddress!.advanced(by: MemoryLayout.size), 196 | count: MemoryLayout.size 197 | ) 198 | ) 199 | } 200 | type = tempType 201 | length = tempLength 202 | } 203 | 204 | var encodedData: Data { 205 | var tempType = type 206 | var tempLength = length 207 | var data = Data(bytes: &tempType, count: MemoryLayout.size) 208 | data.append(Data(bytes: &tempLength, count: MemoryLayout.size)) 209 | return data 210 | } 211 | 212 | static var encodedSize: Int { 213 | return MemoryLayout.size * 2 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /iOS/Client.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import UIKit 4 | import Network 5 | import ActivityKit 6 | 7 | let pingInterval: TimeInterval = 2 8 | let maxPingCount = 5 9 | 10 | class Client: ObservableObject { 11 | static let storage = UserDefaults.standard 12 | 13 | public var connection: NWConnection? = nil { 14 | didSet { 15 | DispatchQueue.main.async { 16 | self.objectWillChange.send() 17 | } 18 | idleManager?.update() 19 | } 20 | } 21 | 22 | let errorPublisher = PassthroughSubject() 23 | 24 | private var ping: (UUID, Date)? = nil 25 | private lazy var pingTimer: Timer? = nil 26 | let pingPublisher = PassthroughSubject() 27 | 28 | @Published var lastServer: NWEndpoint? 29 | @Published var controllerInfo: ClientControllerInfo? = nil 30 | 31 | private var activityCancellable: Cancellable? = nil 32 | 33 | public var idleManager: IdleManager? = nil 34 | 35 | init() { 36 | idleManager = IdleManager(client: self) 37 | if let endpointData = Self.storage.value(forKey: StorageKeys.lastUsedServer.rawValue) as? Data, 38 | let endpointWrapper = try? NSKeyedUnarchiver.unarchivedObject(ofClass: EndpointWrapper.self, from: endpointData) { 39 | lastServer = endpointWrapper.endpoint 40 | } else { 41 | lastServer = nil 42 | } 43 | 44 | if #available(iOS 16.2, *) { 45 | Task { 46 | await ActivityManager.reset() 47 | } 48 | activityCancellable = $controllerInfo.sink { completion in 49 | Task { 50 | await ActivityManager.update(slot: nil) 51 | } 52 | } receiveValue: { value in 53 | Task { 54 | await ActivityManager.update(slot: value?.assignedController) 55 | } 56 | } 57 | } 58 | } 59 | 60 | private var attemptsToReconnect = 0 61 | private var justAttemptedReconnect = false 62 | 63 | func reconnect() { 64 | justAttemptedReconnect = true 65 | DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 1) { 66 | self.justAttemptedReconnect = false 67 | } 68 | if self.connection != nil { 69 | print("Skipping reconnect, still have a connection") 70 | return 71 | } 72 | guard let endpoint = lastServer else { 73 | print("Skipping reconnect, no last server") 74 | return 75 | } 76 | self.connect(to: endpoint) 77 | } 78 | 79 | func connect(to endpoint: Network.NWEndpoint) { 80 | let connection = NWConnection(to: endpoint, using: .custom()) 81 | 82 | var hasBeenReplaced = false 83 | 84 | connection.stateUpdateHandler = { state in 85 | switch state { 86 | case .ready: 87 | self.connection = connection 88 | self.attemptsToReconnect = 0 89 | self.lastServer = endpoint 90 | let wrappedEndpoint = EndpointWrapper(endpoint) 91 | if let endpointData = try? NSKeyedArchiver.archivedData( 92 | withRootObject: wrappedEndpoint, 93 | requiringSecureCoding: false 94 | ) { 95 | Self.storage.setValue( 96 | endpointData, 97 | forKey: StorageKeys.lastUsedServer.rawValue 98 | ) 99 | } 100 | 101 | DispatchQueue.main.async { 102 | self.pingTimer?.invalidate() 103 | let pingTimer = Timer.scheduledTimer( 104 | withTimeInterval: pingInterval, 105 | repeats: true 106 | ) { [weak self] t in 107 | guard let self = self else { 108 | return 109 | } 110 | let uuid = UUID() 111 | let now = Date() 112 | self.ping = (uuid, now) 113 | var bytes = uuid.uuid 114 | let data = Data(bytes: &bytes, count: MemoryLayout.size) 115 | connection.sendMessage(.ping, data: data) 116 | } 117 | pingTimer.fire() 118 | self.pingTimer = pingTimer 119 | } 120 | 121 | if let index = self.controllerInfo?.assignedController { 122 | self.pickController(index: index) 123 | } 124 | 125 | func receiveNextMessage() { 126 | if hasBeenReplaced { 127 | return 128 | } 129 | 130 | connection.receiveMessage { (content, context, isComplete, error) in 131 | if let error = error { 132 | connection.handleReceiveError(error: error) 133 | return 134 | } 135 | 136 | if let message = context?.protocolMetadata(definition: ControllerProtocol.definition) as? NWProtocolFramer.Message { 137 | guard let content = content else { 138 | fatalError("missing content in \(message.controllerMessageType)") 139 | } 140 | 141 | switch message.controllerMessageType { 142 | case .controllerInfo: 143 | let controllerInfo = content.withUnsafeBytes { pointer in 144 | pointer.load(as: ClientControllerInfo.self) 145 | } 146 | DispatchQueue.main.async { 147 | self.controllerInfo = controllerInfo 148 | } 149 | case .errorMessage: 150 | let errorStr = String(data: content, encoding: .utf8) ?? "Unknown error" 151 | self.errorPublisher.send(ControllerProtocol.ProtocolError.errorMessage(errorStr)) 152 | case .ping: 153 | connection.sendMessage(.pong, data: content) 154 | case .pong: 155 | let uuid = content.withUnsafeBytes { pointer in 156 | pointer.load(as: UUID.self) 157 | } 158 | guard let lastPing = self.ping else { 159 | print("Pong without ping") 160 | DispatchQueue.main.async { 161 | self.pingPublisher.send(nil) 162 | } 163 | return 164 | } 165 | if lastPing.0 != uuid { 166 | print("Pong doesn't match ping") 167 | DispatchQueue.main.async { 168 | self.pingPublisher.send(nil) 169 | } 170 | } 171 | let now = Date() 172 | let pingDuration = lastPing.1.distance(to: now) 173 | DispatchQueue.main.async { 174 | self.pingPublisher.send(pingDuration) 175 | } 176 | case .command, .pickController: 177 | fatalError("unexpected message in client \(message.controllerMessageType)") 178 | } 179 | } 180 | 181 | receiveNextMessage() 182 | } 183 | } 184 | receiveNextMessage() 185 | case .failed(let error): 186 | print("Connection failed with error", error) 187 | if !hasBeenReplaced { 188 | self.connection = nil 189 | if self.attemptsToReconnect < 3 { 190 | self.attemptsToReconnect += 1 191 | print("Attempting reconnect #\(self.attemptsToReconnect) after failure") 192 | self.reconnect() 193 | } else { 194 | DispatchQueue.main.async { 195 | self.controllerInfo = nil 196 | } 197 | } 198 | } 199 | hasBeenReplaced = true 200 | case .cancelled: 201 | print("Connection cancelled, handling gracefully") 202 | if !hasBeenReplaced { 203 | self.connection = nil 204 | self.pingTimer?.invalidate() 205 | // there's an edge case where the connection is cancelled, 206 | // but the app doesn't get the message until after it's 207 | // resumed 208 | if self.justAttemptedReconnect { 209 | self.reconnect() 210 | } else { 211 | DispatchQueue.main.async { 212 | self.controllerInfo = nil 213 | self.pingPublisher.send(nil) 214 | } 215 | } 216 | } 217 | hasBeenReplaced = true 218 | default: 219 | break 220 | } 221 | } 222 | 223 | connection.start(queue: .main) 224 | } 225 | 226 | func pickController(index: UInt8) { 227 | var value = index 228 | let data = Data(bytes: &value, count: MemoryLayout.size) 229 | self.connection?.sendMessage(.pickController, data: data) 230 | } 231 | 232 | func send(_ content: String) { 233 | self.connection?.sendMessage(.command, data: Data(content.utf8)) 234 | } 235 | 236 | func disconnect() { 237 | self.connection?.cancel() 238 | } 239 | } 240 | 241 | -------------------------------------------------------------------------------- /iOS/Views/ControllerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private struct PressButton