diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2ac3a9f95c..d47035cec9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,7 +9,7 @@ on:
jobs:
build:
# runs-on: macos-12
- runs-on: [self-hosted, macOS]
+ runs-on: macos-latest
env:
XCODE_COMMON_BUILD_ARGS: -project Source/iOS/App/DolphiniOS.xcodeproj -derivedDataPath "${{ github.workspace }}/build-Xcode" -sdk iphoneos -destination generic/platform=iOS DOL_PBID_ORGANIZATION_IDENTIFIER="me.oatmealdome" DOL_BUILD_SOURCE="development" CODE_SIGNING_ALLOWED="NO" CODE_SIGNING_REQUIRED="NO"
@@ -52,4 +52,4 @@ jobs:
run: |
"${{ github.workspace }}/Source/iOS/App/Project/Scripts/CreateDeb.sh" "${{ github.workspace }}/archives/Jailbroken.xcarchive/Products/Applications/DolphiniOS.app" "OatmealDome Software" "${{ github.workspace }}/Source/iOS/App/Project/Entitlements/Private.entitlements" "${{ github.workspace }}/Source/iOS/App/Project/Package/Deb/${{ env.DEB_CONTROL_NAME }}.in" "${{ github.workspace }}/Source/iOS/App/Project/Package/Deb/postinst.sh" "${{ github.workspace }}/Source/iOS/App/Project/Package/Deb/postrm.sh" "Applications" "${{ github.workspace }}/products/Jailbroken-Rootful.deb"
"${{ github.workspace }}/Source/iOS/App/Project/Scripts/CreateDeb.sh" "${{ github.workspace }}/archives/Jailbroken.xcarchive/Products/Applications/DolphiniOS.app" "OatmealDome Software" "${{ github.workspace }}/Source/iOS/App/Project/Entitlements/Private.entitlements" "${{ github.workspace }}/Source/iOS/App/Project/Package/Deb/${{ env.DEB_CONTROL_NAME }}-rootless.in" "${{ github.workspace }}/Source/iOS/App/Project/Package/Deb/postinst.sh" "${{ github.workspace }}/Source/iOS/App/Project/Package/Deb/postrm.sh" "var/jb/Applications" "${{ github.workspace }}/products/Jailbroken-Rootless.deb"
-
\ No newline at end of file
+
diff --git a/.github/workflows/dsu-client-ci.yml b/.github/workflows/dsu-client-ci.yml
new file mode 100644
index 0000000000..4c4f5fa564
--- /dev/null
+++ b/.github/workflows/dsu-client-ci.yml
@@ -0,0 +1,119 @@
+name: DSU Client CI
+
+# Workflow de validacion en runners hosteados por GitHub (sin infraestructura
+# propia, sin necesidad de una Mac local).
+#
+# El workflow existente del repo (`build.yml`) hace el archive/IPA/DEB
+# completo pero requiere `runs-on: [self-hosted, macOS]` con el toolchain de
+# jailbreak ya instalado. Este workflow es mas liviano: solo COMPILA (no
+# archiva) el scheme "DiOS (NJB)" en Debug, con firma de codigo deshabilitada,
+# para detectar errores de compilacion en cada push/PR. Pensado especialmente
+# para validar los cambios de la pantalla DSU Client (Controllers/Controllers
+# DsuClient*, Controllers/DsuServerCell*, Controllers/ControllersAddDsuServer*)
+# y cualquier otro cambio en el arbol de iOS.
+#
+# Sobre el runner y la version de Xcode:
+# - `runs-on:` en GitHub Actions usa etiquetas por VERSION DE macOS, no por
+# version de Xcode: macos-26 (Apple Silicon, la mas nueva), macos-15,
+# macos-14 (en baja: deprecada desde el 6/jul/2026, se apaga el 2/nov/2026
+# - no usar). No existe "runs-on: xcode-27".
+# - Xcode se selecciona con un paso dentro del job (`xcode-select` o la
+# action `maxim-lobanov/setup-xcode`), porque cada imagen trae varias
+# versiones de Xcode preinstaladas en paralelo.
+# - Las versiones beta/RC de Xcode (como Xcode 27, en preview publico) solo
+# se instalan en la imagen de macOS mas nueva (macos-26) - por eso el
+# runner tiene que ser macos-26 si se quiere probar contra Xcode 27.
+#
+# Por eso hay DOS jobs:
+# - build-stable: Xcode estable mas reciente -> senal confiable de "esto
+# compila con lo que compilaria un usuario comun hoy".
+# - build-xcode27-preview: Xcode 27 (preview publico) -> para adelantarse a
+# roturas de SDK/toolchain, como haces otros proyectos de emuladores.
+# No bloquea el PR si falla (continue-on-error), porque un preview puede
+# romper por cosas ajenas a este cambio.
+
+on:
+ push:
+ branches: [ master ]
+ paths:
+ - 'Source/iOS/**'
+ - 'Source/Core/InputCommon/ControllerInterface/DualShockUDPClient/**'
+ - '.github/workflows/dsu-client-ci.yml'
+ pull_request:
+ branches: [ master ]
+ paths:
+ - 'Source/iOS/**'
+ - 'Source/Core/InputCommon/ControllerInterface/DualShockUDPClient/**'
+ - '.github/workflows/dsu-client-ci.yml'
+ workflow_dispatch: {}
+
+env:
+ XCODE_COMMON_BUILD_ARGS: -project Source/iOS/App/DolphiniOS.xcodeproj -derivedDataPath "${{ github.workspace }}/build-Xcode" -sdk iphoneos -destination generic/platform=iOS DOL_PBID_ORGANIZATION_IDENTIFIER="me.oatmealdome" DOL_BUILD_SOURCE="development" CODE_SIGNING_ALLOWED="NO" CODE_SIGNING_REQUIRED="NO"
+
+jobs:
+ build-stable:
+ name: Build (Xcode estable)
+ runs-on: macos-26
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: 'recursive'
+
+ - name: Seleccionar Xcode estable mas reciente
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Mostrar version de Xcode seleccionada
+ run: xcodebuild -version
+
+ - name: Compilar DiOS (NJB) - Debug
+ run: |
+ xcodebuild build \
+ -configuration "Debug (Non-Jailbroken)" \
+ -scheme "DiOS (NJB)" \
+ ${{ env.XCODE_COMMON_BUILD_ARGS }}
+
+ - name: Subir el .app compilado (sin firmar) como artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: DolphiniOS-NJB-Debug-xcode-estable
+ path: ${{ github.workspace }}/build-Xcode/Build/Products/Debug*iphoneos/DolphiniOS.app
+ if-no-files-found: ignore
+ retention-days: 14
+
+ build-xcode27-preview:
+ name: Build (Xcode 27 preview)
+ runs-on: macos-26
+ continue-on-error: true
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: 'recursive'
+
+ - name: Seleccionar Xcode 27 (preview publico, incluye betas)
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest
+
+ - name: Mostrar version de Xcode seleccionada
+ run: xcodebuild -version
+
+ - name: Compilar DiOS (NJB) - Debug
+ run: |
+ xcodebuild build \
+ -configuration "Debug (Non-Jailbroken)" \
+ -scheme "DiOS (NJB)" \
+ ${{ env.XCODE_COMMON_BUILD_ARGS }}
+
+ - name: Subir el .app compilado (sin firmar) como artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: DolphiniOS-NJB-Debug-xcode27-preview
+ path: ${{ github.workspace }}/build-Xcode/Build/Products/Debug*iphoneos/DolphiniOS.app
+ if-no-files-found: ignore
+ retention-days: 14
diff --git a/Source/iOS/App/Common/UI/Localization/en.lproj/Core.strings b/Source/iOS/App/Common/UI/Localization/en.lproj/Core.strings
index c00153d610..020261d53e 100644
--- a/Source/iOS/App/Common/UI/Localization/en.lproj/Core.strings
+++ b/Source/iOS/App/Common/UI/Localization/en.lproj/Core.strings
@@ -940,6 +940,7 @@ Would you like to ignore this line and continue parsing?";
"Enable" = "Enable";
"Add..." = "Add...";
"Remove" = "Remove";
+"Please enter a valid server address and port." = "Please enter a valid server address and port.";
"DSU protocol enables the use of input and motion data from compatible sources, like PlayStation, Nintendo Switch and Steam controllers. For setup instructions, refer to this page ." = "DSU protocol enables the use of input and motion data from compatible sources, like PlayStation, Nintendo Switch and Steam controllers. For setup instructions, refer to this page .";
"Size" = "Size";
"Disc" = "Disc";
diff --git a/Source/iOS/App/DolphiniOS.xcodeproj/project.pbxproj b/Source/iOS/App/DolphiniOS.xcodeproj/project.pbxproj
index 3b7b7f7124..11b54d4acb 100644
--- a/Source/iOS/App/DolphiniOS.xcodeproj/project.pbxproj
+++ b/Source/iOS/App/DolphiniOS.xcodeproj/project.pbxproj
@@ -177,6 +177,9 @@
B9DC5FB92884CB7C004BD94A /* GraphicsChoiceCell.mm in Sources */ = {isa = PBXBuildFile; fileRef = B9DC5FB82884CB7C004BD94A /* GraphicsChoiceCell.mm */; };
B9EC1A43295FFFD30013EBE4 /* SoftwareListiOSViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B9EC1A42295FFFD30013EBE4 /* SoftwareListiOSViewController.mm */; };
B9EC28382D21D429000FAE09 /* ControllersTouchscreenViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B9EC28372D21D429000FAE09 /* ControllersTouchscreenViewController.mm */; };
+ CFCE2A026C13DB1B9B62AD4B /* ControllersDsuClientViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = CE99DB7E010EF4A5584CB5DA /* ControllersDsuClientViewController.mm */; };
+ CEAC14FEE3BC2980C98DB85F /* DsuServerCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EDA3BEE4FE789DBD3CFDF55 /* DsuServerCell.m */; };
+ 73169C33207036903039304A /* ControllersAddDsuServerViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = FC8D428F66DEF8467F8DF51C /* ControllersAddDsuServerViewController.mm */; };
B9EC283A2D21DA56000FAE09 /* TCWiiTouchIRMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EC28392D21DA56000FAE09 /* TCWiiTouchIRMode.swift */; };
B9EC283D2D21DE2F000FAE09 /* ControllersTouchscreenIRModeViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B9EC283C2D21DE2F000FAE09 /* ControllersTouchscreenIRModeViewController.mm */; };
B9EFF8532E7F56790029F016 /* AppIcon_Glass.icon in Resources */ = {isa = PBXBuildFile; fileRef = B9EFF8522E7F56790029F016 /* AppIcon_Glass.icon */; };
@@ -548,6 +551,13 @@
B9EC1A42295FFFD30013EBE4 /* SoftwareListiOSViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = SoftwareListiOSViewController.mm; sourceTree = ""; };
B9EC28362D21D429000FAE09 /* ControllersTouchscreenViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ControllersTouchscreenViewController.h; sourceTree = ""; };
B9EC28372D21D429000FAE09 /* ControllersTouchscreenViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ControllersTouchscreenViewController.mm; sourceTree = ""; };
+ E723480BA2A352B04E8FD38E /* ControllersDsuClientViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ControllersDsuClientViewController.h; sourceTree = ""; };
+ CE99DB7E010EF4A5584CB5DA /* ControllersDsuClientViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ControllersDsuClientViewController.mm; sourceTree = ""; };
+ C623440D4EF2976092F76843 /* DsuServerCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DsuServerCell.h; sourceTree = ""; };
+ 4EDA3BEE4FE789DBD3CFDF55 /* DsuServerCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DsuServerCell.m; sourceTree = ""; };
+ BA352A1E6E317A3FBBBC4A87 /* ControllersAddDsuServerViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ControllersAddDsuServerViewController.h; sourceTree = ""; };
+ FC8D428F66DEF8467F8DF51C /* ControllersAddDsuServerViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ControllersAddDsuServerViewController.mm; sourceTree = ""; };
+ 088316AAD0B43DC1AB1ED40C /* ControllersAddDsuServerViewControllerDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ControllersAddDsuServerViewControllerDelegate.h; sourceTree = ""; };
B9EC28392D21DA56000FAE09 /* TCWiiTouchIRMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCWiiTouchIRMode.swift; sourceTree = ""; };
B9EC283B2D21DE2F000FAE09 /* ControllersTouchscreenIRModeViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ControllersTouchscreenIRModeViewController.h; sourceTree = ""; };
B9EC283C2D21DE2F000FAE09 /* ControllersTouchscreenIRModeViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ControllersTouchscreenIRModeViewController.mm; sourceTree = ""; };
@@ -770,6 +780,13 @@
B9EC28372D21D429000FAE09 /* ControllersTouchscreenViewController.mm */,
B9EC283B2D21DE2F000FAE09 /* ControllersTouchscreenIRModeViewController.h */,
B9EC283C2D21DE2F000FAE09 /* ControllersTouchscreenIRModeViewController.mm */,
+ E723480BA2A352B04E8FD38E /* ControllersDsuClientViewController.h */,
+ CE99DB7E010EF4A5584CB5DA /* ControllersDsuClientViewController.mm */,
+ C623440D4EF2976092F76843 /* DsuServerCell.h */,
+ 4EDA3BEE4FE789DBD3CFDF55 /* DsuServerCell.m */,
+ BA352A1E6E317A3FBBBC4A87 /* ControllersAddDsuServerViewController.h */,
+ FC8D428F66DEF8467F8DF51C /* ControllersAddDsuServerViewController.mm */,
+ 088316AAD0B43DC1AB1ED40C /* ControllersAddDsuServerViewControllerDelegate.h */,
);
path = Controllers;
sourceTree = "";
@@ -2135,6 +2152,9 @@
B9B2C661288E569E00CB6569 /* ExternalDisplayEmulationViewController.mm in Sources */,
B950CFA32961696100197719 /* BootNoticeNavigationViewController.m in Sources */,
B9EC28382D21D429000FAE09 /* ControllersTouchscreenViewController.mm in Sources */,
+ CFCE2A026C13DB1B9B62AD4B /* ControllersDsuClientViewController.mm in Sources */,
+ CEAC14FEE3BC2980C98DB85F /* DsuServerCell.m in Sources */,
+ 73169C33207036903039304A /* ControllersAddDsuServerViewController.mm in Sources */,
B9B1483C29642CB8002C4BE7 /* UpdateRequiredNoticeViewController.m in Sources */,
B900A7B027F2866D000361DD /* UserFolderUtil.swift in Sources */,
B974923C2960096C00794509 /* ImportFileManager.m in Sources */,
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewController.h b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewController.h
new file mode 100644
index 0000000000..1e81460151
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewController.h
@@ -0,0 +1,22 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import
+
+@protocol ControllersAddDsuServerViewControllerDelegate;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface ControllersAddDsuServerViewController : UITableViewController
+
+@property (weak, nonatomic, nullable) id delegate;
+
+@property (weak, nonatomic) IBOutlet UITextField* descriptionField;
+@property (weak, nonatomic) IBOutlet UITextField* addressField;
+@property (weak, nonatomic) IBOutlet UITextField* portField;
+
+- (IBAction)addPressed:(id)sender;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewController.mm b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewController.mm
new file mode 100644
index 0000000000..3602b2d8f1
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewController.mm
@@ -0,0 +1,74 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import "ControllersAddDsuServerViewController.h"
+
+#import
+
+#import "InputCommon/ControllerInterface/DualShockUDPClient/DualShockUDPClient.h"
+
+#import "ControllersAddDsuServerViewControllerDelegate.h"
+#import "FoundationStringUtil.h"
+#import "LocalizationUtil.h"
+
+@interface ControllersAddDsuServerViewController ()
+
+@end
+
+@implementation ControllersAddDsuServerViewController
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ self.navigationItem.title = DOLCoreLocalizedString(@"Add New DSU Server");
+
+ self.descriptionField.placeholder = DOLCoreLocalizedString(@"BetterJoy, DS4Windows, etc");
+ self.addressField.text = CppToFoundationString(std::string(ciface::DualShockUDPClient::DEFAULT_SERVER_ADDRESS));
+ self.portField.text = [NSString stringWithFormat:@"%d", ciface::DualShockUDPClient::DEFAULT_SERVER_PORT];
+}
+
+// Mirrors ServerStringValidator: the description and address fields are
+// used as fields in a "description:address:port;" serialized entry, so
+// ':' and ';' can never be allowed in them.
+- (BOOL)textField:(UITextField*)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString*)string {
+ if (textField != self.descriptionField && textField != self.addressField) {
+ return true;
+ }
+
+ return [string rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@":;"]].location == NSNotFound;
+}
+
+- (void)showInvalidInputAlert {
+ UIAlertController* alert = [UIAlertController alertControllerWithTitle:DOLCoreLocalizedString(@"Error")
+ message:DOLCoreLocalizedString(@"Please enter a valid server address and port.")
+ preferredStyle:UIAlertControllerStyleAlert];
+
+ [alert addAction:[UIAlertAction actionWithTitle:DOLCoreLocalizedString(@"OK") style:UIAlertActionStyleDefault handler:nil]];
+
+ [self presentViewController:alert animated:true completion:nil];
+}
+
+- (IBAction)addPressed:(id)sender {
+ NSString* description = [self.descriptionField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ NSString* address = [self.addressField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
+
+ if (description.length == 0 || address.length == 0) {
+ [self showInvalidInputAlert];
+ return;
+ }
+
+ NSInteger port = [self.portField.text integerValue];
+ if (port <= 0 || port > 0xFFFF) {
+ [self showInvalidInputAlert];
+ return;
+ }
+
+ [self.delegate addDsuServerViewController:self
+ didAddServerWithDescription:description
+ address:address
+ port:(uint16_t)port];
+
+ [self.navigationController popViewControllerAnimated:true];
+}
+
+@end
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewControllerDelegate.h b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewControllerDelegate.h
new file mode 100644
index 0000000000..b01dbe0979
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersAddDsuServerViewControllerDelegate.h
@@ -0,0 +1,19 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import
+
+@class ControllersAddDsuServerViewController;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol ControllersAddDsuServerViewControllerDelegate
+
+- (void)addDsuServerViewController:(ControllersAddDsuServerViewController*)viewController
+ didAddServerWithDescription:(NSString*)description
+ address:(NSString*)address
+ port:(uint16_t)port;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersDsuClientViewController.h b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersDsuClientViewController.h
new file mode 100644
index 0000000000..ca3c9981a3
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersDsuClientViewController.h
@@ -0,0 +1,17 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface ControllersDsuClientViewController : UITableViewController
+
+@property (weak, nonatomic) IBOutlet UISwitch* enabledSwitch;
+@property (weak, nonatomic) IBOutlet UITextView* descriptionTextView;
+
+- (IBAction)enabledSwitchChanged:(id)sender;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersDsuClientViewController.mm b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersDsuClientViewController.mm
new file mode 100644
index 0000000000..bb4dbe3dee
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersDsuClientViewController.mm
@@ -0,0 +1,246 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import "ControllersDsuClientViewController.h"
+
+#import
+#import
+
+#import
+
+#import "Common/CommonTypes.h"
+#import "Common/Config/Config.h"
+#import "Common/StringUtil.h"
+
+#import "InputCommon/ControllerInterface/DualShockUDPClient/DualShockUDPClient.h"
+
+#import "ControllersAddDsuServerViewController.h"
+#import "ControllersAddDsuServerViewControllerDelegate.h"
+#import "DsuServerCell.h"
+#import "FoundationStringUtil.h"
+#import "LocalizationUtil.h"
+
+namespace
+{
+struct DsuServerEntry
+{
+ std::string description;
+ std::string address;
+ u16 port;
+};
+
+// Mirrors ciface::DualShockUDPClient::InputBackend::ConfigChanged and
+// DualShockUDPClientWidget::RefreshServerList: entries are stored as
+// "description:address:port;" and old single-server settings (kept only
+// for backwards compatibility) are folded into the new list on first read.
+std::vector LoadDsuServers()
+{
+ const auto legacy_address = Config::Get(ciface::DualShockUDPClient::Settings::SERVER_ADDRESS);
+ const auto legacy_port = Config::Get(ciface::DualShockUDPClient::Settings::SERVER_PORT);
+
+ if (!legacy_address.empty() && legacy_port != 0)
+ {
+ const auto& current_setting = Config::Get(ciface::DualShockUDPClient::Settings::SERVERS);
+ Config::SetBaseOrCurrent(ciface::DualShockUDPClient::Settings::SERVERS,
+ current_setting + fmt::format("{}:{}:{};", "DS4", legacy_address,
+ legacy_port));
+ Config::SetBase(ciface::DualShockUDPClient::Settings::SERVER_ADDRESS, "");
+ Config::SetBase(ciface::DualShockUDPClient::Settings::SERVER_PORT, 0);
+ }
+
+ std::vector servers;
+
+ const auto& servers_setting = Config::Get(ciface::DualShockUDPClient::Settings::SERVERS);
+ for (const auto& entry : SplitString(servers_setting, ';'))
+ {
+ const auto parts = SplitString(entry, ':');
+ if (parts.size() < 3)
+ {
+ continue;
+ }
+
+ const int port = std::atoi(parts[2].c_str());
+ if (port < 0 || port > 0xFFFF)
+ {
+ continue;
+ }
+
+ servers.push_back({parts[0], parts[1], static_cast(port)});
+ }
+
+ return servers;
+}
+
+void SaveDsuServers(const std::vector& servers)
+{
+ std::string new_setting;
+ for (const auto& server : servers)
+ {
+ new_setting += fmt::format("{}:{}:{};", server.description, server.address, server.port);
+ }
+
+ Config::SetBaseOrCurrent(ciface::DualShockUDPClient::Settings::SERVERS, new_setting);
+}
+} // namespace
+
+@interface ControllersDsuClientViewController ()
+
+@end
+
+@implementation ControllersDsuClientViewController {
+ std::vector _servers;
+}
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ self.title = DOLCoreLocalizedString(@"DSU Client");
+
+ NSString* htmlDescription = DOLCoreLocalizedString(
+ @"DSU protocol enables the use of input and motion data from compatible "
+ @"sources, like PlayStation, Nintendo Switch and Steam controllers. "
+ @"For setup instructions, "
+ @""
+ @"refer to this page .");
+
+ NSData* htmlData = [htmlDescription dataUsingEncoding:NSUTF8StringEncoding];
+ NSDictionary* options = @{
+ NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType,
+ NSCharacterEncodingDocumentAttribute : @(NSUTF8StringEncoding)
+ };
+
+ NSAttributedString* attributedDescription =
+ [[NSAttributedString alloc] initWithData:htmlData options:options
+ documentAttributes:nil error:nil];
+
+ if (attributedDescription != nil) {
+ self.descriptionTextView.attributedText = attributedDescription;
+ } else {
+ // Fall back to plain text if, for whatever reason, the HTML failed to parse.
+ self.descriptionTextView.text = htmlDescription;
+ }
+
+ self.descriptionTextView.font = [UIFont systemFontOfSize:15];
+ self.descriptionTextView.textColor = [UIColor secondaryLabelColor];
+}
+
+- (void)viewWillAppear:(BOOL)animated {
+ [super viewWillAppear:animated];
+
+ self->_servers = LoadDsuServers();
+
+ self.enabledSwitch.on = Config::Get(ciface::DualShockUDPClient::Settings::SERVERS_ENABLED);
+
+ [self.tableView reloadData];
+}
+
+#pragma mark - Table view data source
+
+- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView {
+ return 3;
+}
+
+- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section {
+ switch (section) {
+ case 0: // Enable
+ return 1;
+ case 1: // Servers + Add row
+ return self->_servers.size() + 1;
+ default: // Description
+ return 1;
+ }
+}
+
+- (UITableViewCell*)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath {
+ if (indexPath.section == 0) {
+ return [tableView dequeueReusableCellWithIdentifier:@"enableCell" forIndexPath:indexPath];
+ }
+
+ if (indexPath.section == 1) {
+ if ((NSUInteger)indexPath.row == self->_servers.size()) {
+ UITableViewCell* addCell = [tableView dequeueReusableCellWithIdentifier:@"addCell" forIndexPath:indexPath];
+ addCell.userInteractionEnabled = self.enabledSwitch.on;
+ addCell.textLabel.alpha = self.enabledSwitch.on ? 1.0 : 0.5;
+ return addCell;
+ }
+
+ DsuServerCell* serverCell = [tableView dequeueReusableCellWithIdentifier:@"serverCell" forIndexPath:indexPath];
+
+ const auto& server = self->_servers[indexPath.row];
+ serverCell.nameLabel.text = [NSString stringWithFormat:@"%@:%@ - %@",
+ CppToFoundationString(server.address),
+ @(server.port),
+ CppToFoundationString(server.description)];
+ serverCell.nameLabel.alpha = self.enabledSwitch.on ? 1.0 : 0.5;
+
+ return serverCell;
+ }
+
+ return [tableView dequeueReusableCellWithIdentifier:@"descriptionCell" forIndexPath:indexPath];
+}
+
+- (void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath {
+ [tableView deselectRowAtIndexPath:indexPath animated:true];
+
+ if (indexPath.section != 1 || (NSUInteger)indexPath.row != self->_servers.size()) {
+ return;
+ }
+
+ if (!self.enabledSwitch.on) {
+ return;
+ }
+
+ [self performSegueWithIdentifier:@"addServer" sender:nil];
+}
+
+- (BOOL)tableView:(UITableView*)tableView canEditRowAtIndexPath:(NSIndexPath*)indexPath {
+ if (indexPath.section != 1 || (NSUInteger)indexPath.row == self->_servers.size()) {
+ return false;
+ }
+
+ return self.enabledSwitch.on;
+}
+
+- (void)tableView:(UITableView*)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath*)indexPath {
+ if (editingStyle != UITableViewCellEditingStyleDelete) {
+ return;
+ }
+
+ self->_servers.erase(self->_servers.begin() + indexPath.row);
+ SaveDsuServers(self->_servers);
+
+ [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
+}
+
+#pragma mark - Actions
+
+- (IBAction)enabledSwitchChanged:(id)sender {
+ Config::SetBaseOrCurrent(ciface::DualShockUDPClient::Settings::SERVERS_ENABLED, (bool)self.enabledSwitch.on);
+
+ // Section 1 needs to redraw: the server rows and the "Add..." row both
+ // reflect the enabled state (dimmed + non-interactive when disabled).
+ [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:1] withRowAnimation:UITableViewRowAnimationAutomatic];
+}
+
+#pragma mark - Navigation
+
+- (void)prepareForSegue:(UIStoryboardSegue*)segue sender:(id)sender {
+ if ([segue.identifier isEqualToString:@"addServer"]) {
+ ControllersAddDsuServerViewController* addController = segue.destinationViewController;
+ addController.delegate = self;
+ }
+}
+
+#pragma mark - ControllersAddDsuServerViewControllerDelegate
+
+- (void)addDsuServerViewController:(ControllersAddDsuServerViewController*)viewController
+ didAddServerWithDescription:(NSString*)description
+ address:(NSString*)address
+ port:(uint16_t)port {
+ self->_servers.push_back({FoundationToCppString(description), FoundationToCppString(address), port});
+ SaveDsuServers(self->_servers);
+
+ [self.tableView reloadData];
+}
+
+@end
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersSettings.storyboard b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersSettings.storyboard
index 157662d7fc..637c8e1c36 100644
--- a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersSettings.storyboard
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/ControllersSettings.storyboard
@@ -330,6 +330,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -691,5 +716,255 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/DsuServerCell.h b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/DsuServerCell.h
new file mode 100644
index 0000000000..bf4716cab2
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/DsuServerCell.h
@@ -0,0 +1,14 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface DsuServerCell : UITableViewCell
+
+@property (weak, nonatomic) IBOutlet UILabel* nameLabel;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/DsuServerCell.m b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/DsuServerCell.m
new file mode 100644
index 0000000000..aa2a640ad4
--- /dev/null
+++ b/Source/iOS/App/DolphiniOS/UI/Settings/Controllers/DsuServerCell.m
@@ -0,0 +1,8 @@
+// Copyright 2026 DolphiniOS Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#import "DsuServerCell.h"
+
+@implementation DsuServerCell
+
+@end