├── 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 | |
|  |  |
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 | 
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 | 
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