From 837069904de6088e6e87cc6420c84ed66ac25161 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 16 Aug 2025 20:10:40 -0500 Subject: [PATCH 01/34] Bump minimum iOS version requirement to iOS 15. Bump RxSwift dependency version to 6.9.0 --- Example/RIBs.xcodeproj/project.pbxproj | 2 ++ Package.swift | 11 +++++++---- RIBs.xcodeproj/project.pbxproj | 4 ++-- .../tutorial1/TicTacToe.xcodeproj/project.pbxproj | 2 ++ .../tutorial2/TicTacToe.xcodeproj/project.pbxproj | 2 ++ .../TicTacToe.xcodeproj/project.pbxproj | 2 ++ .../tutorial3/TicTacToe.xcodeproj/project.pbxproj | 2 ++ .../TicTacToe.xcodeproj/project.pbxproj | 2 ++ .../tutorial4/TicTacToe.xcodeproj/project.pbxproj | 2 ++ 9 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Example/RIBs.xcodeproj/project.pbxproj b/Example/RIBs.xcodeproj/project.pbxproj index e810a05..e361f11 100644 --- a/Example/RIBs.xcodeproj/project.pbxproj +++ b/Example/RIBs.xcodeproj/project.pbxproj @@ -373,6 +373,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_FILE = RIBs/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; @@ -388,6 +389,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_FILE = RIBs/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; diff --git a/Package.swift b/Package.swift index b49d031..2ee5ba3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,22 +1,25 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.5 import PackageDescription let package = Package( name: "RIBs", platforms: [ - .iOS(.v9), + .iOS("15.0"), ], products: [ .library(name: "RIBs", targets: ["RIBs"]), ], dependencies: [ - .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.5.0"), + .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.9.0"), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", from: "2.2.2"), // for testTarget only ], targets: [ .target( name: "RIBs", - dependencies: ["RxSwift", "RxRelay"], + dependencies: [ + .product(name: "RxSwift", package: "RxSwift"), + .product(name: "RxRelay", package: "RxSwift") + ], path: "RIBs" ), .testTarget( diff --git a/RIBs.xcodeproj/project.pbxproj b/RIBs.xcodeproj/project.pbxproj index b5ae108..049e88a 100644 --- a/RIBs.xcodeproj/project.pbxproj +++ b/RIBs.xcodeproj/project.pbxproj @@ -560,7 +560,7 @@ ); INFOPLIST_FILE = RIBs/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.uber.RIBs; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; @@ -587,7 +587,7 @@ ); INFOPLIST_FILE = RIBs/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.uber.RIBs; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; diff --git a/tutorials/tutorial1/TicTacToe.xcodeproj/project.pbxproj b/tutorials/tutorial1/TicTacToe.xcodeproj/project.pbxproj index 0c8b1ff..4de8c28 100644 --- a/tutorials/tutorial1/TicTacToe.xcodeproj/project.pbxproj +++ b/tutorials/tutorial1/TicTacToe.xcodeproj/project.pbxproj @@ -384,6 +384,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -398,6 +399,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/tutorials/tutorial2/TicTacToe.xcodeproj/project.pbxproj b/tutorials/tutorial2/TicTacToe.xcodeproj/project.pbxproj index 0f04442..cfef53a 100644 --- a/tutorials/tutorial2/TicTacToe.xcodeproj/project.pbxproj +++ b/tutorials/tutorial2/TicTacToe.xcodeproj/project.pbxproj @@ -569,6 +569,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -583,6 +584,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/tutorials/tutorial3-completed/TicTacToe.xcodeproj/project.pbxproj b/tutorials/tutorial3-completed/TicTacToe.xcodeproj/project.pbxproj index b432bd0..c0ed3d1 100644 --- a/tutorials/tutorial3-completed/TicTacToe.xcodeproj/project.pbxproj +++ b/tutorials/tutorial3-completed/TicTacToe.xcodeproj/project.pbxproj @@ -488,6 +488,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -502,6 +503,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/tutorials/tutorial3/TicTacToe.xcodeproj/project.pbxproj b/tutorials/tutorial3/TicTacToe.xcodeproj/project.pbxproj index 6e9d0e1..110f276 100644 --- a/tutorials/tutorial3/TicTacToe.xcodeproj/project.pbxproj +++ b/tutorials/tutorial3/TicTacToe.xcodeproj/project.pbxproj @@ -476,6 +476,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -490,6 +491,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/tutorials/tutorial4-completed/TicTacToe.xcodeproj/project.pbxproj b/tutorials/tutorial4-completed/TicTacToe.xcodeproj/project.pbxproj index 9c9f2d5..ab67949 100644 --- a/tutorials/tutorial4-completed/TicTacToe.xcodeproj/project.pbxproj +++ b/tutorials/tutorial4-completed/TicTacToe.xcodeproj/project.pbxproj @@ -584,6 +584,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -598,6 +599,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/tutorials/tutorial4/TicTacToe.xcodeproj/project.pbxproj b/tutorials/tutorial4/TicTacToe.xcodeproj/project.pbxproj index 5de64de..3cd8cfb 100644 --- a/tutorials/tutorial4/TicTacToe.xcodeproj/project.pbxproj +++ b/tutorials/tutorial4/TicTacToe.xcodeproj/project.pbxproj @@ -580,6 +580,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -594,6 +595,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TicTacToe/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.ubercab.TicTacToe; PRODUCT_NAME = "$(TARGET_NAME)"; From 55074126ab5a861967d7ed704140066df8e8cd21 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 16 Aug 2025 20:18:08 -0500 Subject: [PATCH 02/34] Bump iOS version in cocoapods --- RIBs.podspec | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/RIBs.podspec b/RIBs.podspec index b443369..fb0a69b 100644 --- a/RIBs.podspec +++ b/RIBs.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'RIBs' - s.version = '0.9.3' + s.version = '1.0.0' s.summary = 'Uber\'s cross-platform mobile architecture.' s.description = <<-DESC RIBs is the cross-platform architecture behind many mobile apps at Uber. This architecture framework is designed for mobile apps with a large number of engineers and nested states. @@ -8,12 +8,12 @@ RIBs is the cross-platform architecture behind many mobile apps at Uber. This ar s.homepage = 'https://github.com/uber/RIBs-iOS' s.license = { :type => 'Apache License, Version 2.0', :file => 'LICENSE.txt' } s.author = { 'uber' => 'mobile-open-source@uber.com' } - s.source = { :git => 'https://github.com/uber/RIBs-iOS.git', :tag => 'v' + s.version.to_s } - s.ios.deployment_target = '9.0' + s.source = { :git => 'https://github.com/uber/RIBs-iOS.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' s.swift_version = '5.0' s.source_files = 'RIBs/Classes/**/*' - s.dependency 'RxSwift', '~> 6.5.0' - s.dependency 'RxRelay', '~> 6.5.0' + s.dependency 'RxSwift', '~> 6.9.0' + s.dependency 'RxRelay', '~> 6.9.0' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'RIBsTests/**/*.swift' From b565c3005e2a3593f2aa42d69bfd0cb61beea3be Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 30 Aug 2025 11:40:44 -0500 Subject: [PATCH 03/34] Fix tests in CI --- Example/.ruby-version | 1 + Example/Gemfile | 5 ++ Example/Gemfile.lock | 118 +++++++++++++++++++++++++ Example/Podfile | 2 +- Example/RIBs.xcodeproj/project.pbxproj | 4 + 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 Example/.ruby-version create mode 100644 Example/Gemfile create mode 100644 Example/Gemfile.lock diff --git a/Example/.ruby-version b/Example/.ruby-version new file mode 100644 index 0000000..fd2a018 --- /dev/null +++ b/Example/.ruby-version @@ -0,0 +1 @@ +3.1.0 diff --git a/Example/Gemfile b/Example/Gemfile new file mode 100644 index 0000000..a55e88c --- /dev/null +++ b/Example/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem 'cocoapods' diff --git a/Example/Gemfile.lock b/Example/Gemfile.lock new file mode 100644 index 0000000..ab81a50 --- /dev/null +++ b/Example/Gemfile.lock @@ -0,0 +1,118 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + activesupport (7.2.2.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.2) + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + drb (2.2.3) + escape (0.0.4) + ethon (0.15.0) + ffi (>= 1.15.0) + ffi (1.17.2) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) + concurrent-ruby (~> 1.0) + json (2.13.2) + logger (1.7.0) + minitest (5.25.5) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + netrc (0.11.0) + nkf (0.2.0) + public_suffix (4.0.7) + rexml (3.4.2) + ruby-macho (2.5.1) + securerandom (0.4.1) + typhoeus (1.5.0) + ethon (>= 0.9.0, < 0.16.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + +PLATFORMS + arm64-darwin + ruby + x86_64-darwin + +DEPENDENCIES + cocoapods + +BUNDLED WITH + 2.6.9 diff --git a/Example/Podfile b/Example/Podfile index 5faa4fd..6c660d9 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -1,6 +1,6 @@ use_frameworks! -platform :ios, '10.0' +platform :ios, '15.0' target 'RIBs_Example' do pod 'RIBs', :path => '../', :testspecs => ['Tests'] diff --git a/Example/RIBs.xcodeproj/project.pbxproj b/Example/RIBs.xcodeproj/project.pbxproj index e361f11..45519b9 100644 --- a/Example/RIBs.xcodeproj/project.pbxproj +++ b/Example/RIBs.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; + 7EAB71912E635DC2003F72CC /* Podfile in Resources */ = {isa = PBXBuildFile; fileRef = 7EAB71902E635DC2003F72CC /* Podfile */; }; F72111BE3D6D8AF7B540B2D5 /* Pods_RIBs_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 904C7089FA30546DB519E5B6 /* Pods_RIBs_Example.framework */; }; /* End PBXBuildFile section */ @@ -26,6 +27,7 @@ 607FACDC1AFB9204008FA782 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 79412201A539E9C0ACDDCA67 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; + 7EAB71902E635DC2003F72CC /* Podfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Podfile; sourceTree = ""; }; 8352C4F16B235B2430F102DC /* Pods-RIBs_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RIBs_Example.release.xcconfig"; path = "Target Support Files/Pods-RIBs_Example/Pods-RIBs_Example.release.xcconfig"; sourceTree = ""; }; 84C807AE5AA5710BFA3EE5FC /* Pods-RIBs_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RIBs_Tests.release.xcconfig"; path = "Target Support Files/Pods-RIBs_Tests/Pods-RIBs_Tests.release.xcconfig"; sourceTree = ""; }; 904C7089FA30546DB519E5B6 /* Pods_RIBs_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RIBs_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -49,6 +51,7 @@ 607FACC71AFB9204008FA782 = { isa = PBXGroup; children = ( + 7EAB71902E635DC2003F72CC /* Podfile */, 607FACF51AFB993E008FA782 /* Podspec Metadata */, 607FACD21AFB9204008FA782 /* Example for RIBs */, 607FACD11AFB9204008FA782 /* Products */, @@ -181,6 +184,7 @@ files = ( 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */, 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */, + 7EAB71912E635DC2003F72CC /* Podfile in Resources */, 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; From 5d269e928455fd31489514672e4e412a1252dcd3 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 30 Aug 2025 12:17:15 -0500 Subject: [PATCH 04/34] Lock in the RxSwift dependency to 6.x.x --- Package.swift | 2 +- RIBs.podspec | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 2ee5ba3..53d3f9b 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library(name: "RIBs", targets: ["RIBs"]), ], dependencies: [ - .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.9.0"), + .package(url: "https://github.com/ReactiveX/RxSwift", "6.9.0"..<"7.0.0"), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", from: "2.2.2"), // for testTarget only ], targets: [ diff --git a/RIBs.podspec b/RIBs.podspec index fb0a69b..38f8ecd 100644 --- a/RIBs.podspec +++ b/RIBs.podspec @@ -12,9 +12,9 @@ RIBs is the cross-platform architecture behind many mobile apps at Uber. This ar s.ios.deployment_target = '15.0' s.swift_version = '5.0' s.source_files = 'RIBs/Classes/**/*' - s.dependency 'RxSwift', '~> 6.9.0' - s.dependency 'RxRelay', '~> 6.9.0' - + s.dependency 'RxSwift', '~> 6.0' + s.dependency 'RxRelay', '~> 6.0' + s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'RIBsTests/**/*.swift' test_spec.dependency 'CwlPreconditionTesting' From 3a4624dec48ed963bb1c666cc54ed4ec8e970da5 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 30 Aug 2025 12:21:00 -0500 Subject: [PATCH 05/34] Fixing CI --- .github/workflows/iOS.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index 653178e..d0929ad 100644 --- a/.github/workflows/iOS.yml +++ b/.github/workflows/iOS.yml @@ -29,7 +29,7 @@ jobs: -workspace RIBs.xcworkspace \ -scheme RIBs-Example \ -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ -enableCodeCoverage YES \ clean test From 4e8a08d115451b15ec6ba9f657b134be192ab1bb Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 30 Aug 2025 12:33:04 -0500 Subject: [PATCH 06/34] Version 1.0.0 --- CHANGELOG.md | 5 +++++ README.md | 4 ++-- RIBs/Info.plist | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f55f8eb..cf34102 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,3 +136,8 @@ * Increase buffer capacity for mutableRouterEvents flow within RibEvents by @RahulDMello in https://github.com/uber/RIBs/pull/635 * Add test asserting Rx subscription is disposed after `RibCoroutineWor… by @psteiger in https://github.com/uber/RIBs/pull/628 +### Version 1.0.0 + +* Bumps RxSwift dependency version to 6.x.x (6.9.0 at the time of the release) by @alexvbush +* Adds Swift Package Manager (SPM) setup by @alexvbush +* Improves CocoaPods and Carthage setup by @alexvbush \ No newline at end of file diff --git a/README.md b/README.md index 8cfc107..5534153 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ For usage of the tooling built around RIBs, please see the [Tooling section](htt To integrate RIBs into your project add the following to your `Podfile`: ```ruby -pod 'RIBs', '~> 0.9' +pod 'RIBs', '~> 1.0' ``` #### Carthage @@ -58,7 +58,7 @@ pod 'RIBs', '~> 0.9' To integrate RIBs into your project using Carthage add the following to your `Cartfile`: ```ruby -github "uber/RIBs" ~> 0.9 +github "uber/RIBs" ~> 1.0 ``` ## Related projects diff --git a/RIBs/Info.plist b/RIBs/Info.plist index 992c476..4c0d218 100644 --- a/RIBs/Info.plist +++ b/RIBs/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 0.9.3 + 1.0.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass From 0bed2237e7929bb77219f79db096ac9c92fe380a Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 30 Aug 2025 12:41:06 -0500 Subject: [PATCH 07/34] Update readme --- README.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5534153..2372f11 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,25 @@ There are some other novel things about RIBs. However, these could also be imple For usage of the tooling built around RIBs, please see the [Tooling section](https://github.com/uber/RIBs/wiki#rib-tooling) in our documentation. ## Installation for iOS -#### CocoaPods + +### Swift Package Manager (Recommended) + +To integrate RIBs into your project using Swift Package Manager: + +1. In Xcode, go to **File** → **Add Package Dependencies** +2. Enter the repository URL: `https://github.com/uber/RIBs-iOS.git` +3. Select the version constraint: `~> 1.0` +4. Click **Add Package** + +Alternatively, you can add it to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/uber/RIBs-iOS.git", from: "1.0.0") +] +``` + +### CocoaPods To integrate RIBs into your project add the following to your `Podfile`: @@ -53,7 +71,7 @@ To integrate RIBs into your project add the following to your `Podfile`: pod 'RIBs', '~> 1.0' ``` -#### Carthage +### Carthage To integrate RIBs into your project using Carthage add the following to your `Cartfile`: @@ -61,9 +79,24 @@ To integrate RIBs into your project using Carthage add the following to your `Ca github "uber/RIBs" ~> 1.0 ``` +## Dependencies + +When you integrate RIBs into your project, it will automatically bring the following dependencies: + +### Core Dependencies +- **RxSwift** (~> 6.0) - Reactive programming library for Swift +- **RxRelay** (~> 6.0) - Reactive relays for state management + +### Platform Requirements +- **iOS 15.0+** - Minimum deployment target +- **Swift 5.0+** - Required Swift version + +These dependencies are automatically managed by your chosen package manager and will be resolved to compatible versions. + ## Related projects If you like RIBs, check out other related open source projects from our team: +- [RIBs-Android](https://github.com/uber/RIBs): Android version of RIBs framework implementation - [Needle](https://github.com/uber/needle): a compile-time safe Swift dependency injection framework. - [Motif](https://github.com/uber/motif): An abstract on top of Dagger offering simpler APIs for nested scopes. - [Swift Concurrency](https://github.com/uber/swift-concurrency): a set of concurrency utility classes used by Uber, inspired by the equivalent [java.util.concurrent](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/package-summary.html) package classes. From afa08eb1d248d839dde6efe8e9718d59fecd3c0e Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 16 Aug 2025 20:10:40 -0500 Subject: [PATCH 08/34] Bump minimum iOS version requirement to iOS 15. Bump RxSwift dependency version to 6.9.0 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 53d3f9b..2ee5ba3 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library(name: "RIBs", targets: ["RIBs"]), ], dependencies: [ - .package(url: "https://github.com/ReactiveX/RxSwift", "6.9.0"..<"7.0.0"), + .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.9.0"), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", from: "2.2.2"), // for testTarget only ], targets: [ From 48d16f64109b65480e0c99ea22f67a3ac1e290d2 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 16 Aug 2025 20:18:08 -0500 Subject: [PATCH 09/34] Bump iOS version in cocoapods --- RIBs.podspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RIBs.podspec b/RIBs.podspec index 38f8ecd..3f41d28 100644 --- a/RIBs.podspec +++ b/RIBs.podspec @@ -12,8 +12,8 @@ RIBs is the cross-platform architecture behind many mobile apps at Uber. This ar s.ios.deployment_target = '15.0' s.swift_version = '5.0' s.source_files = 'RIBs/Classes/**/*' - s.dependency 'RxSwift', '~> 6.0' - s.dependency 'RxRelay', '~> 6.0' + s.dependency 'RxSwift', '~> 6.9.0' + s.dependency 'RxRelay', '~> 6.9.0' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'RIBsTests/**/*.swift' From 78950a16d747b27e644e1767e29fec8f45e3bcab Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 30 Aug 2025 12:17:15 -0500 Subject: [PATCH 10/34] Lock in the RxSwift dependency to 6.x.x --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 2ee5ba3..53d3f9b 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library(name: "RIBs", targets: ["RIBs"]), ], dependencies: [ - .package(url: "https://github.com/ReactiveX/RxSwift", from: "6.9.0"), + .package(url: "https://github.com/ReactiveX/RxSwift", "6.9.0"..<"7.0.0"), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", from: "2.2.2"), // for testTarget only ], targets: [ From 30f4b84dff8e85c6f16e3b10ec0787bf09354d02 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 10 Jan 2026 13:06:16 -0600 Subject: [PATCH 11/34] Add a new small example ribs app --- {Example => Examples/Example1}/.ruby-version | 0 {Example => Examples/Example1}/Gemfile | 0 {Example => Examples/Example1}/Gemfile.lock | 0 {Example => Examples/Example1}/Podfile | 0 .../Example1}/RIBs.xcodeproj/project.pbxproj | 0 .../xcschemes/RIBs-Example.xcscheme | 0 .../Example1}/RIBs/AppDelegate.swift | 0 .../RIBs/Base.lproj/LaunchScreen.xib | 0 .../Example1}/RIBs/Base.lproj/Main.storyboard | 0 .../AppIcon.appiconset/Contents.json | 0 .../Example1}/RIBs/Info.plist | 0 .../Example1}/RIBs/ViewController.swift | 0 .../RIBsAppExample2.xcodeproj/project.pbxproj | 487 ++++++++++++++++++ .../RIBsAppExample2/AppComponent.swift | 9 + .../RIBsAppExample2/AppDelegate.swift | 36 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../Base.lproj/Main.storyboard | 24 + .../FirstViewableRIBBuilder.swift | 41 ++ .../FirstViewableRIBInteractor.swift | 54 ++ .../FirstViewableRIBRouter.swift | 49 ++ .../FirstViewableRIBViewController.swift | 27 + .../RIBsAppExample2/Info.plist | 25 + .../RIBsAppExample2/Root/RootBuilder.swift | 40 ++ .../RIBsAppExample2/Root/RootInteractor.swift | 47 ++ .../RIBsAppExample2/Root/RootRouter.swift | 47 ++ .../Root/RootViewController.swift | 44 ++ .../RIBsAppExample2/SceneDelegate.swift | 31 ++ .../SecondViewableRIBBuilder.swift | 39 ++ .../SecondViewableRIBInteractor.swift | 45 ++ .../SecondViewableRIBRouter.swift | 30 ++ .../SecondViewableRIBViewController.swift | 27 + .../RIBsAppExample2/ViewController.swift | 19 + .../RIBsAppExample2Tests.swift | 36 ++ 36 files changed, 1234 insertions(+) rename {Example => Examples/Example1}/.ruby-version (100%) rename {Example => Examples/Example1}/Gemfile (100%) rename {Example => Examples/Example1}/Gemfile.lock (100%) rename {Example => Examples/Example1}/Podfile (100%) rename {Example => Examples/Example1}/RIBs.xcodeproj/project.pbxproj (100%) rename {Example => Examples/Example1}/RIBs.xcodeproj/xcshareddata/xcschemes/RIBs-Example.xcscheme (100%) rename {Example => Examples/Example1}/RIBs/AppDelegate.swift (100%) rename {Example => Examples/Example1}/RIBs/Base.lproj/LaunchScreen.xib (100%) rename {Example => Examples/Example1}/RIBs/Base.lproj/Main.storyboard (100%) rename {Example => Examples/Example1}/RIBs/Images.xcassets/AppIcon.appiconset/Contents.json (100%) rename {Example => Examples/Example1}/RIBs/Info.plist (100%) rename {Example => Examples/Example1}/RIBs/ViewController.swift (100%) create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/AppComponent.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/AppDelegate.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/Contents.json create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/LaunchScreen.storyboard create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/Main.storyboard create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Info.plist create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Root/RootViewController.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/ViewController.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2Tests/RIBsAppExample2Tests.swift diff --git a/Example/.ruby-version b/Examples/Example1/.ruby-version similarity index 100% rename from Example/.ruby-version rename to Examples/Example1/.ruby-version diff --git a/Example/Gemfile b/Examples/Example1/Gemfile similarity index 100% rename from Example/Gemfile rename to Examples/Example1/Gemfile diff --git a/Example/Gemfile.lock b/Examples/Example1/Gemfile.lock similarity index 100% rename from Example/Gemfile.lock rename to Examples/Example1/Gemfile.lock diff --git a/Example/Podfile b/Examples/Example1/Podfile similarity index 100% rename from Example/Podfile rename to Examples/Example1/Podfile diff --git a/Example/RIBs.xcodeproj/project.pbxproj b/Examples/Example1/RIBs.xcodeproj/project.pbxproj similarity index 100% rename from Example/RIBs.xcodeproj/project.pbxproj rename to Examples/Example1/RIBs.xcodeproj/project.pbxproj diff --git a/Example/RIBs.xcodeproj/xcshareddata/xcschemes/RIBs-Example.xcscheme b/Examples/Example1/RIBs.xcodeproj/xcshareddata/xcschemes/RIBs-Example.xcscheme similarity index 100% rename from Example/RIBs.xcodeproj/xcshareddata/xcschemes/RIBs-Example.xcscheme rename to Examples/Example1/RIBs.xcodeproj/xcshareddata/xcschemes/RIBs-Example.xcscheme diff --git a/Example/RIBs/AppDelegate.swift b/Examples/Example1/RIBs/AppDelegate.swift similarity index 100% rename from Example/RIBs/AppDelegate.swift rename to Examples/Example1/RIBs/AppDelegate.swift diff --git a/Example/RIBs/Base.lproj/LaunchScreen.xib b/Examples/Example1/RIBs/Base.lproj/LaunchScreen.xib similarity index 100% rename from Example/RIBs/Base.lproj/LaunchScreen.xib rename to Examples/Example1/RIBs/Base.lproj/LaunchScreen.xib diff --git a/Example/RIBs/Base.lproj/Main.storyboard b/Examples/Example1/RIBs/Base.lproj/Main.storyboard similarity index 100% rename from Example/RIBs/Base.lproj/Main.storyboard rename to Examples/Example1/RIBs/Base.lproj/Main.storyboard diff --git a/Example/RIBs/Images.xcassets/AppIcon.appiconset/Contents.json b/Examples/Example1/RIBs/Images.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Example/RIBs/Images.xcassets/AppIcon.appiconset/Contents.json rename to Examples/Example1/RIBs/Images.xcassets/AppIcon.appiconset/Contents.json diff --git a/Example/RIBs/Info.plist b/Examples/Example1/RIBs/Info.plist similarity index 100% rename from Example/RIBs/Info.plist rename to Examples/Example1/RIBs/Info.plist diff --git a/Example/RIBs/ViewController.swift b/Examples/Example1/RIBs/ViewController.swift similarity index 100% rename from Example/RIBs/ViewController.swift rename to Examples/Example1/RIBs/ViewController.swift diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj new file mode 100644 index 0000000..64bcd00 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -0,0 +1,487 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EB78D4E2F12CC0000547345 /* RIBs */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7EB78D302F12CB3A00547345 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7EB78D112F12CB3800547345 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7EB78D182F12CB3800547345; + remoteInfo = RIBsAppExample2; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RIBsAppExample2.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7EB78D2F2F12CB3A00547345 /* RIBsAppExample2Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RIBsAppExample2Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7EB78D412F12CB3A00547345 /* Exceptions for "RIBsAppExample2" folder in "RIBsAppExample2" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7EB78D182F12CB3800547345 /* RIBsAppExample2 */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7EB78D1B2F12CB3800547345 /* RIBsAppExample2 */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EB78D412F12CB3A00547345 /* Exceptions for "RIBsAppExample2" folder in "RIBsAppExample2" target */, + ); + path = RIBsAppExample2; + sourceTree = ""; + }; + 7EB78D322F12CB3A00547345 /* RIBsAppExample2Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RIBsAppExample2Tests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7EB78D162F12CB3800547345 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7EB78D2C2F12CB3A00547345 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7EB78D102F12CB3800547345 = { + isa = PBXGroup; + children = ( + 7EB78D1B2F12CB3800547345 /* RIBsAppExample2 */, + 7EB78D322F12CB3A00547345 /* RIBsAppExample2Tests */, + 7EB78D1A2F12CB3800547345 /* Products */, + ); + sourceTree = ""; + }; + 7EB78D1A2F12CB3800547345 /* Products */ = { + isa = PBXGroup; + children = ( + 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */, + 7EB78D2F2F12CB3A00547345 /* RIBsAppExample2Tests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7EB78D182F12CB3800547345 /* RIBsAppExample2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7EB78D422F12CB3A00547345 /* Build configuration list for PBXNativeTarget "RIBsAppExample2" */; + buildPhases = ( + 7EB78D152F12CB3800547345 /* Sources */, + 7EB78D162F12CB3800547345 /* Frameworks */, + 7EB78D172F12CB3800547345 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7EB78D1B2F12CB3800547345 /* RIBsAppExample2 */, + ); + name = RIBsAppExample2; + packageProductDependencies = ( + 7EB78D4E2F12CC0000547345 /* RIBs */, + ); + productName = RIBsAppExample2; + productReference = 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */; + productType = "com.apple.product-type.application"; + }; + 7EB78D2E2F12CB3A00547345 /* RIBsAppExample2Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7EB78D472F12CB3A00547345 /* Build configuration list for PBXNativeTarget "RIBsAppExample2Tests" */; + buildPhases = ( + 7EB78D2B2F12CB3A00547345 /* Sources */, + 7EB78D2C2F12CB3A00547345 /* Frameworks */, + 7EB78D2D2F12CB3A00547345 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7EB78D312F12CB3A00547345 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7EB78D322F12CB3A00547345 /* RIBsAppExample2Tests */, + ); + name = RIBsAppExample2Tests; + packageProductDependencies = ( + ); + productName = RIBsAppExample2Tests; + productReference = 7EB78D2F2F12CB3A00547345 /* RIBsAppExample2Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7EB78D112F12CB3800547345 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + 7EB78D182F12CB3800547345 = { + CreatedOnToolsVersion = 16.4; + }; + 7EB78D2E2F12CB3A00547345 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 7EB78D182F12CB3800547345; + }; + }; + }; + buildConfigurationList = 7EB78D142F12CB3800547345 /* Build configuration list for PBXProject "RIBsAppExample2" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7EB78D102F12CB3800547345; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7EB78D4D2F12CC0000547345 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 7EB78D1A2F12CB3800547345 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7EB78D182F12CB3800547345 /* RIBsAppExample2 */, + 7EB78D2E2F12CB3A00547345 /* RIBsAppExample2Tests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7EB78D172F12CB3800547345 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7EB78D2D2F12CB3A00547345 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7EB78D152F12CB3800547345 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7EB78D2B2F12CB3A00547345 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7EB78D312F12CB3A00547345 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7EB78D182F12CB3800547345 /* RIBsAppExample2 */; + targetProxy = 7EB78D302F12CB3A00547345 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 7EB78D432F12CB3A00547345 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A6WM5VZ38B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RIBsAppExample2/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7EB78D442F12CB3A00547345 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A6WM5VZ38B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RIBsAppExample2/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 7EB78D452F12CB3A00547345 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = A6WM5VZ38B; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7EB78D462F12CB3A00547345 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = A6WM5VZ38B; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7EB78D482F12CB3A00547345 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A6WM5VZ38B; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RIBsAppExample2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RIBsAppExample2"; + }; + name = Debug; + }; + 7EB78D492F12CB3A00547345 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A6WM5VZ38B; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RIBsAppExample2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RIBsAppExample2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7EB78D142F12CB3800547345 /* Build configuration list for PBXProject "RIBsAppExample2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7EB78D452F12CB3A00547345 /* Debug */, + 7EB78D462F12CB3A00547345 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7EB78D422F12CB3A00547345 /* Build configuration list for PBXNativeTarget "RIBsAppExample2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7EB78D432F12CB3A00547345 /* Debug */, + 7EB78D442F12CB3A00547345 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7EB78D472F12CB3A00547345 /* Build configuration list for PBXNativeTarget "RIBsAppExample2Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7EB78D482F12CB3A00547345 /* Debug */, + 7EB78D492F12CB3A00547345 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 7EB78D4D2F12CC0000547345 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../RIBs-iOS"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7EB78D4E2F12CC0000547345 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + productName = RIBs; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 7EB78D112F12CB3800547345 /* Project object */; +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/AppComponent.swift b/Examples/RIBsAppExample2/RIBsAppExample2/AppComponent.swift new file mode 100644 index 0000000..71296dc --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/AppComponent.swift @@ -0,0 +1,9 @@ +import RIBs + +class AppComponent: Component, RootDependency { + + init() { + super.init(dependency: EmptyComponent()) + } + +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/AppDelegate.swift b/Examples/RIBsAppExample2/RIBsAppExample2/AppDelegate.swift new file mode 100644 index 0000000..b00f3af --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/Contents.json b/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/LaunchScreen.storyboard b/Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/Main.storyboard b/Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift new file mode 100644 index 0000000..72f5702 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift @@ -0,0 +1,41 @@ +// +// FirstViewableRIBBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol FirstViewableRIBDependency: Dependency { + // TODO: Declare the set of dependencies required by this RIB, but cannot be + // created by this RIB. +} + +final class FirstViewableRIBComponent: Component, SecondViewableRIBDependency { + + var secondViewableRIBBuilder: SecondViewableRIBBuildable { + SecondViewableRIBBuilder(dependency: self) + } +} + +// MARK: - Builder + +protocol FirstViewableRIBBuildable: Buildable { + func build(withListener listener: FirstViewableRIBListener) -> FirstViewableRIBRouting +} + +final class FirstViewableRIBBuilder: Builder, FirstViewableRIBBuildable { + + override init(dependency: FirstViewableRIBDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: FirstViewableRIBListener) -> FirstViewableRIBRouting { + let component = FirstViewableRIBComponent(dependency: dependency) + let viewController = FirstViewableRIBViewController() + let interactor = FirstViewableRIBInteractor(presenter: viewController) + interactor.listener = listener + return FirstViewableRIBRouter(interactor: interactor, viewController: viewController, secondViewableRIBBuilder: component.secondViewableRIBBuilder) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift new file mode 100644 index 0000000..7c28b97 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift @@ -0,0 +1,54 @@ +// +// FirstViewableRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift + +protocol FirstViewableRIBRouting: ViewableRouting { + var firstViewableRIBViewController: FirstViewableRIBViewControllable { get } + func routeToSecondViewableRIB() + func routeAwayFromSecondViewableRIB() +} + +protocol FirstViewableRIBPresentable: Presentable { + var listener: FirstViewableRIBPresentableListener? { get set } + // TODO: Declare methods the interactor can invoke the presenter to present data. +} + +protocol FirstViewableRIBListener: AnyObject { + // TODO: Declare methods the interactor can invoke to communicate with other RIBs. +} + +final class FirstViewableRIBInteractor: PresentableInteractor, FirstViewableRIBInteractable, FirstViewableRIBPresentableListener { + + weak var router: FirstViewableRIBRouting? + weak var listener: FirstViewableRIBListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: FirstViewableRIBPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + Single + .timer(.seconds(3), scheduler: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { [weak self] _ in + self?.router?.routeToSecondViewableRIB() + }) + .disposeOnDeactivate(interactor: self) + } + + override func willResignActive() { + super.willResignActive() + // TODO: Pause any business logic. + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift new file mode 100644 index 0000000..20f3ee4 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift @@ -0,0 +1,49 @@ +// +// FirstViewableRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol FirstViewableRIBInteractable: Interactable, SecondViewableRIBListener { + var router: FirstViewableRIBRouting? { get set } + var listener: FirstViewableRIBListener? { get set } +} + +protocol FirstViewableRIBViewControllable: ViewControllable { + +} + +final class FirstViewableRIBRouter: ViewableRouter, FirstViewableRIBRouting { + + private let secondViewableRIBBuilder: SecondViewableRIBBuildable + private var secondViewableRIBRouter: SecondViewableRIBRouting? + + init(interactor: FirstViewableRIBInteractable, viewController: FirstViewableRIBViewControllable, secondViewableRIBBuilder: SecondViewableRIBBuildable) { + self.secondViewableRIBBuilder = secondViewableRIBBuilder + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } + + var firstViewableRIBViewController: any FirstViewableRIBViewControllable { + viewController + } + + func routeToSecondViewableRIB() { + let secondViewableRIBRouter = secondViewableRIBBuilder.build(withListener: interactor) + self.secondViewableRIBRouter = secondViewableRIBRouter + let secondViewableRIBViewControllable = secondViewableRIBRouter.secondViewableRIBViewController + attachChild(secondViewableRIBRouter) + viewController.uiviewController.navigationController?.pushViewController(secondViewableRIBViewControllable.uiviewController, animated: true) + } + + func routeAwayFromSecondViewableRIB() { + if let secondViewableRIBRouter = secondViewableRIBRouter { + self.secondViewableRIBRouter = nil + viewController.uiviewController.navigationController?.popToViewController(viewController.uiviewController, animated: true) + detachChild(secondViewableRIBRouter) + } + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift new file mode 100644 index 0000000..9847ac4 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift @@ -0,0 +1,27 @@ +// +// FirstViewableRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import UIKit + +protocol FirstViewableRIBPresentableListener: AnyObject { + // TODO: Declare properties and methods that the view controller can invoke to perform + // business logic, such as signIn(). This protocol is implemented by the corresponding + // interactor class. +} + +final class FirstViewableRIBViewController: UIViewController, FirstViewableRIBPresentable, FirstViewableRIBViewControllable { + + weak var listener: FirstViewableRIBPresentableListener? + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemGreen + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist b/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist new file mode 100644 index 0000000..dd3c9af --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift new file mode 100644 index 0000000..29e7baf --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift @@ -0,0 +1,40 @@ +// +// RootBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol RootDependency: Dependency { + // TODO: Declare the set of dependencies required by this RIB, but cannot be + // created by this RIB. +} + +final class RootComponent: Component, FirstViewableRIBDependency { + + var firstViewableRIBBuilder: FirstViewableRIBBuildable { + FirstViewableRIBBuilder(dependency: self) + } +} + +// MARK: - Builder + +protocol RootBuildable: Buildable { + func build() -> LaunchRouting +} + +final class RootBuilder: Builder, RootBuildable { + + override init(dependency: RootDependency) { + super.init(dependency: dependency) + } + + func build() -> LaunchRouting { + let component = RootComponent(dependency: dependency) + let viewController = RootViewController() + let interactor = RootInteractor(presenter: viewController) + return RootRouter(interactor: interactor, viewController: viewController, firstViewableRIBBuilder: component.firstViewableRIBBuilder) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift new file mode 100644 index 0000000..91ff2b0 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift @@ -0,0 +1,47 @@ +// +// RootInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift + +protocol RootRouting: ViewableRouting { + func routeToFirstViewableRIB() + func routeAwayFromFirstViewableRIB() +} + +protocol RootPresentable: Presentable { + var listener: RootPresentableListener? { get set } + // TODO: Declare methods the interactor can invoke the presenter to present data. +} + +protocol RootListener: AnyObject { + // TODO: Declare methods the interactor can invoke to communicate with other RIBs. +} + +final class RootInteractor: PresentableInteractor, RootInteractable, RootPresentableListener { + + weak var router: RootRouting? + weak var listener: RootListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: RootPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + router?.routeToFirstViewableRIB() + } + + override func willResignActive() { + super.willResignActive() + // TODO: Pause any business logic. + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift new file mode 100644 index 0000000..02a7bb4 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift @@ -0,0 +1,47 @@ +// +// RootRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol RootInteractable: Interactable, FirstViewableRIBListener { + var router: RootRouting? { get set } + var listener: RootListener? { get set } +} + +protocol RootViewControllable: ViewControllable { + func embedMainView(_ viewControllable: ViewControllable) + func removeMainView(_ viewControllable: ViewControllable) +} + +final class RootRouter: LaunchRouter, RootRouting { + + private let firstViewableRIBBuilder: FirstViewableRIBBuildable + private var firstViewableRIBRouter: FirstViewableRIBRouting? + + init(interactor: RootInteractable, viewController: RootViewControllable, firstViewableRIBBuilder: FirstViewableRIBBuildable) { + self.firstViewableRIBBuilder = firstViewableRIBBuilder + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } + + func routeToFirstViewableRIB() { + let firstViewableRIBRouter = firstViewableRIBBuilder.build(withListener: interactor) + self.firstViewableRIBRouter = firstViewableRIBRouter + let firstViewableRIBViewController = firstViewableRIBRouter.firstViewableRIBViewController + viewController.embedMainView(firstViewableRIBViewController) + attachChild(firstViewableRIBRouter) + } + + func routeAwayFromFirstViewableRIB() { + if let firstViewableRIBRouter = firstViewableRIBRouter { + self.firstViewableRIBRouter = nil + let firstViewableRIBViewController = firstViewableRIBRouter.firstViewableRIBViewController + viewController.removeMainView(firstViewableRIBViewController) + detachChild(firstViewableRIBRouter) + } + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootViewController.swift new file mode 100644 index 0000000..77efd27 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootViewController.swift @@ -0,0 +1,44 @@ +// +// RootViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import UIKit + +protocol RootPresentableListener: AnyObject { + // TODO: Declare properties and methods that the view controller can invoke to perform + // business logic, such as signIn(). This protocol is implemented by the corresponding + // interactor class. +} + +final class RootViewController: UIViewController, RootPresentable, RootViewControllable { + + weak var listener: RootPresentableListener? + + func embedMainView(_ viewControllable: ViewControllable) { + let navController = UINavigationController(rootViewController: viewControllable.uiviewController) + + addChild(navController) + view.addSubview(navController.view) + navController.didMove(toParent: self) + let childView = navController.view! + childView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + childView.topAnchor.constraint(equalTo: self.view.topAnchor), + childView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + childView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + childView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor) + ]) + } + + func removeMainView(_ viewControllable: ViewControllable) { + guard let navController = viewControllable.uiviewController.navigationController else { return } + navController.willMove(toParent: nil) + navController.view.removeFromSuperview() + navController.removeFromParent() + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift new file mode 100644 index 0000000..5e7dbff --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift @@ -0,0 +1,31 @@ +// +// SceneDelegate.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import UIKit +import RIBs + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + private var launchRouter: LaunchRouting? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + + let appComponent = AppComponent() + + let window = UIWindow(windowScene: windowScene) + self.window = window + + let launchRouter = RootBuilder(dependency: appComponent).build() + self.launchRouter = launchRouter + launchRouter.launch(from: window) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift new file mode 100644 index 0000000..486e885 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift @@ -0,0 +1,39 @@ +// +// SecondViewableRIBBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol SecondViewableRIBDependency: Dependency { + // TODO: Declare the set of dependencies required by this RIB, but cannot be + // created by this RIB. +} + +final class SecondViewableRIBComponent: Component { + + // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. +} + +// MARK: - Builder + +protocol SecondViewableRIBBuildable: Buildable { + func build(withListener listener: SecondViewableRIBListener) -> SecondViewableRIBRouting +} + +final class SecondViewableRIBBuilder: Builder, SecondViewableRIBBuildable { + + override init(dependency: SecondViewableRIBDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: SecondViewableRIBListener) -> SecondViewableRIBRouting { + let component = SecondViewableRIBComponent(dependency: dependency) + let viewController = SecondViewableRIBViewController() + let interactor = SecondViewableRIBInteractor(presenter: viewController) + interactor.listener = listener + return SecondViewableRIBRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift new file mode 100644 index 0000000..9a68913 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift @@ -0,0 +1,45 @@ +// +// SecondViewableRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift + +protocol SecondViewableRIBRouting: ViewableRouting { + var secondViewableRIBViewController: SecondViewableRIBViewControllable { get } +} + +protocol SecondViewableRIBPresentable: Presentable { + var listener: SecondViewableRIBPresentableListener? { get set } + // TODO: Declare methods the interactor can invoke the presenter to present data. +} + +protocol SecondViewableRIBListener: AnyObject { + // TODO: Declare methods the interactor can invoke to communicate with other RIBs. +} + +final class SecondViewableRIBInteractor: PresentableInteractor, SecondViewableRIBInteractable, SecondViewableRIBPresentableListener { + + weak var router: SecondViewableRIBRouting? + weak var listener: SecondViewableRIBListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: SecondViewableRIBPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + // TODO: Implement business logic here. + } + + override func willResignActive() { + super.willResignActive() + // TODO: Pause any business logic. + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift new file mode 100644 index 0000000..3ff70f1 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift @@ -0,0 +1,30 @@ +// +// SecondViewableRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol SecondViewableRIBInteractable: Interactable { + var router: SecondViewableRIBRouting? { get set } + var listener: SecondViewableRIBListener? { get set } +} + +protocol SecondViewableRIBViewControllable: ViewControllable { + +} + +final class SecondViewableRIBRouter: ViewableRouter, SecondViewableRIBRouting { + + // TODO: Constructor inject child builder protocols to allow building children. + override init(interactor: SecondViewableRIBInteractable, viewController: SecondViewableRIBViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } + + var secondViewableRIBViewController: any SecondViewableRIBViewControllable { + viewController + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift new file mode 100644 index 0000000..e18803e --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift @@ -0,0 +1,27 @@ +// +// SecondViewableRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import UIKit + +protocol SecondViewableRIBPresentableListener: AnyObject { + // TODO: Declare properties and methods that the view controller can invoke to perform + // business logic, such as signIn(). This protocol is implemented by the corresponding + // interactor class. +} + +final class SecondViewableRIBViewController: UIViewController, SecondViewableRIBPresentable, SecondViewableRIBViewControllable { + + weak var listener: SecondViewableRIBPresentableListener? + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .blue + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ViewController.swift new file mode 100644 index 0000000..371fe7d --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ViewController.swift @@ -0,0 +1,19 @@ +// +// ViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import UIKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + } + + +} + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2Tests/RIBsAppExample2Tests.swift b/Examples/RIBsAppExample2/RIBsAppExample2Tests/RIBsAppExample2Tests.swift new file mode 100644 index 0000000..05a61d7 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2Tests/RIBsAppExample2Tests.swift @@ -0,0 +1,36 @@ +// +// RIBsAppExample2Tests.swift +// RIBsAppExample2Tests +// +// Created by Alex Bush on 1/10/26. +// + +import XCTest +@testable import RIBsAppExample2 + +final class RIBsAppExample2Tests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} From 47e666c120ef647790429548481013e8755de928 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 10 Jan 2026 13:25:08 -0600 Subject: [PATCH 12/34] Make it compile with Xcode 16.4 and Xcode 26.0.1 - deinit isolation issue still persists for now, the code in them was commented out --- .../RIBsAppExample2.xcodeproj/project.pbxproj | 2 ++ RIBs/Classes/Builder.swift | 2 ++ RIBs/Classes/DI/Component.swift | 2 ++ RIBs/Classes/DI/Dependency.swift | 2 ++ RIBs/Classes/Interactor.swift | 17 +++++++---- RIBs/Classes/LaunchRouter.swift | 2 ++ RIBs/Classes/PresentableInteractor.swift | 5 +++- RIBs/Classes/Router.swift | 28 +++++++++++-------- RIBs/Classes/ViewableRouter.swift | 2 ++ RIBs/Classes/Worker/Worker.swift | 13 ++++++--- RIBs/Classes/Workflow/Workflow.swift | 3 ++ 11 files changed, 56 insertions(+), 22 deletions(-) diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index 64bcd00..e8cc093 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -244,6 +244,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -272,6 +273,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/RIBs/Classes/Builder.swift b/RIBs/Classes/Builder.swift index 9f16e9d..b04f8b2 100644 --- a/RIBs/Classes/Builder.swift +++ b/RIBs/Classes/Builder.swift @@ -17,9 +17,11 @@ import Foundation /// The base builder protocol that all builders should conform to. +@MainActor public protocol Buildable: AnyObject {} /// Utility that instantiates a RIB and sets up its internal wirings. +@MainActor open class Builder: Buildable { /// The dependency used for this builder to build the RIB. diff --git a/RIBs/Classes/DI/Component.swift b/RIBs/Classes/DI/Component.swift index 4a178ff..aa5995f 100644 --- a/RIBs/Classes/DI/Component.swift +++ b/RIBs/Classes/DI/Component.swift @@ -23,6 +23,7 @@ import Foundation /// /// A component subclass implementation should conform to child 'Dependency' protocols, defined by all of its immediate /// children. +@MainActor open class Component: Dependency { /// The dependency of this `Component`. @@ -70,6 +71,7 @@ open class Component: Dependency { } /// The special empty component. +@MainActor open class EmptyComponent: EmptyDependency { /// Initializer. diff --git a/RIBs/Classes/DI/Dependency.swift b/RIBs/Classes/DI/Dependency.swift index a45cc17..36fff40 100644 --- a/RIBs/Classes/DI/Dependency.swift +++ b/RIBs/Classes/DI/Dependency.swift @@ -20,7 +20,9 @@ import Foundation /// /// Subclasses should define a set of properties that are required by the module from the DI graph. A dependency is /// typically provided and satisfied by its immediate parent module. +@MainActor public protocol Dependency: AnyObject {} /// The special empty dependency. +@MainActor public protocol EmptyDependency: Dependency {} diff --git a/RIBs/Classes/Interactor.swift b/RIBs/Classes/Interactor.swift index d80ded2..73cfc83 100644 --- a/RIBs/Classes/Interactor.swift +++ b/RIBs/Classes/Interactor.swift @@ -19,6 +19,7 @@ import RxSwift import UIKit /// Protocol defining the activeness of an interactor's scope. +@MainActor public protocol InteractorScope: AnyObject { // The following properties must be declared in the base protocol, since `Router` internally invokes these methods. @@ -37,6 +38,7 @@ public protocol InteractorScope: AnyObject { } /// The base protocol for all interactors. +@MainActor public protocol Interactable: InteractorScope { // The following methods must be declared in the base protocol, since `Router` internally invokes these methods. @@ -64,6 +66,7 @@ public protocol Interactable: InteractorScope { /// active. /// /// An `Interactor` should only perform its business logic when it's currently active. +@MainActor open class Interactor: Interactable { /// Indicates if the interactor is active. @@ -140,10 +143,12 @@ open class Interactor: Interactable { fileprivate var activenessDisposable: CompositeDisposable? deinit { - if isActive { - deactivate() - } - isActiveSubject.onCompleted() + // TODO: deal with this later + +// if isActive { +// deactivate() +// } +// isActiveSubject.onCompleted() } } @@ -162,7 +167,7 @@ public extension ObservableType { /// /// - parameter interactorScope: The interactor scope whose activeness this observable is confined to. /// - returns: The `Observable` confined to this interactor's activeness lifecycle. - + @MainActor func confineTo(_ interactorScope: InteractorScope) -> Observable { return Observable .combineLatest(interactorScope.isActiveStream, self) { isActive, value in @@ -194,7 +199,7 @@ public extension Disposable { /// terminated. /// /// - parameter interactor: The interactor to dispose the subscription based on. - @discardableResult + @discardableResult @MainActor func disposeOnDeactivate(interactor: Interactor) -> Disposable { if let activenessDisposable = interactor.activenessDisposable { _ = activenessDisposable.insert(self) diff --git a/RIBs/Classes/LaunchRouter.swift b/RIBs/Classes/LaunchRouter.swift index d50dc20..96e58e9 100644 --- a/RIBs/Classes/LaunchRouter.swift +++ b/RIBs/Classes/LaunchRouter.swift @@ -17,6 +17,7 @@ import UIKit /// The root `Router` of an application. +@MainActor public protocol LaunchRouting: ViewableRouting { /// Launches the router tree. @@ -26,6 +27,7 @@ public protocol LaunchRouting: ViewableRouting { } /// The application root router base class, that acts as the root of the router tree. +@MainActor open class LaunchRouter: ViewableRouter, LaunchRouting { /// Initializer. diff --git a/RIBs/Classes/PresentableInteractor.swift b/RIBs/Classes/PresentableInteractor.swift index bafdccd..ca30f16 100644 --- a/RIBs/Classes/PresentableInteractor.swift +++ b/RIBs/Classes/PresentableInteractor.swift @@ -17,6 +17,7 @@ import Foundation /// Base class of an `Interactor` that actually has an associated `Presenter` and `View`. +@MainActor open class PresentableInteractor: Interactor { /// The `Presenter` associated with this `Interactor`. @@ -34,6 +35,8 @@ open class PresentableInteractor: Interactor { // MARK: - Private deinit { - LeakDetector.instance.expectDeallocate(object: presenter as AnyObject) + // TODO: deal with this later + +// LeakDetector.instance.expectDeallocate(object: presenter as AnyObject) } } diff --git a/RIBs/Classes/Router.swift b/RIBs/Classes/Router.swift index de593d0..6a36f29 100644 --- a/RIBs/Classes/Router.swift +++ b/RIBs/Classes/Router.swift @@ -17,6 +17,7 @@ import RxSwift /// The lifecycle stages of a router scope. +@MainActor public enum RouterLifecycle { /// Router did load. @@ -24,6 +25,7 @@ public enum RouterLifecycle { } /// The scope of a `Router`, defining various lifecycles of a `Router`. +@MainActor public protocol RouterScope: AnyObject { /// An observable that emits values when the router scope reaches its corresponding life-cycle stages. This @@ -32,6 +34,7 @@ public protocol RouterScope: AnyObject { } /// The base protocol for all routers. +@MainActor public protocol Routing: RouterScope { // The following methods must be declared in the base protocol, since `Router` internally invokes these methods. @@ -73,6 +76,7 @@ public protocol Routing: RouterScope { /// Router drives the lifecycle of its owned `Interactor`. /// /// Routers should always use helper builders to instantiate children routers. +@MainActor open class Router: Routing { /// The corresponding `Interactor` owned by this `Router`. @@ -212,16 +216,18 @@ open class Router: Routing { } deinit { - interactable.deactivate() - - if !children.isEmpty { - detachAllChildren() - } - - lifecycleSubject.onCompleted() - - deinitDisposable.dispose() - - LeakDetector.instance.expectDeallocate(object: interactable) + // TODO: get back to this + +// interactable.deactivate() +// +// if !children.isEmpty { +// detachAllChildren() +// } +// +// lifecycleSubject.onCompleted() +// +// deinitDisposable.dispose() +// +// LeakDetector.instance.expectDeallocate(object: interactable) } } diff --git a/RIBs/Classes/ViewableRouter.swift b/RIBs/Classes/ViewableRouter.swift index 82488b4..942b2c0 100644 --- a/RIBs/Classes/ViewableRouter.swift +++ b/RIBs/Classes/ViewableRouter.swift @@ -17,6 +17,7 @@ import RxSwift /// The base protocol for all routers that own their own view controllers. +@MainActor public protocol ViewableRouting: Routing { // The following methods must be declared in the base protocol, since `Router` internally invokes these methods. @@ -33,6 +34,7 @@ public protocol ViewableRouting: Routing { /// A `Router` acts on inputs from its corresponding interactor, to manipulate application state and view state, /// forming a tree of routers that drives the tree of view controllers. Router drives the lifecycle of its owned /// interactor. `Router`s should always use helper builders to instantiate children `Router`s. +@MainActor open class ViewableRouter: Router, ViewableRouting { /// The corresponding `ViewController` owned by this `Router`. diff --git a/RIBs/Classes/Worker/Worker.swift b/RIBs/Classes/Worker/Worker.swift index e8f4669..e05de40 100644 --- a/RIBs/Classes/Worker/Worker.swift +++ b/RIBs/Classes/Worker/Worker.swift @@ -20,6 +20,7 @@ import RxSwift /// /// `Worker`s are always bound to an `Interactor`. A `Worker` can only start if its bound `Interactor` is active. /// It is stopped when its bound interactor is deactivated. +@MainActor public protocol Working: AnyObject { /// Starts the `Worker`. @@ -46,6 +47,7 @@ public protocol Working: AnyObject { } /// The base `Worker` implementation. +@MainActor open class Worker: Working { /// Indicates if the `Worker` is started. @@ -168,9 +170,11 @@ open class Worker: Working { } deinit { - stop() - unbindInteractor() - isStartedSubject.onCompleted() + // TODO: deal with this later + +// stop() +// unbindInteractor() +// isStartedSubject.onCompleted() } } @@ -187,7 +191,7 @@ public extension Disposable { /// `worker` needs to be deallocated. /// /// - parameter worker: The `Worker` to dispose the subscription based on. - @discardableResult + @discardableResult @MainActor func disposeOnStop(_ worker: Worker) -> Disposable { if let compositeDisposable = worker.disposable { _ = compositeDisposable.insert(self) @@ -199,6 +203,7 @@ public extension Disposable { } } +@MainActor fileprivate class WeakInteractorScope: InteractorScope { weak var sourceScope: InteractorScope? diff --git a/RIBs/Classes/Workflow/Workflow.swift b/RIBs/Classes/Workflow/Workflow.swift index 472ffa3..29d8082 100644 --- a/RIBs/Classes/Workflow/Workflow.swift +++ b/RIBs/Classes/Workflow/Workflow.swift @@ -23,6 +23,7 @@ import RxSwift /// RIB. /// /// A workflow should always start at the root of the tree. +@MainActor open class Workflow { /// Called when the last step observable is completed. @@ -104,6 +105,7 @@ open class Workflow { /// steps. /// /// Steps are asynchronous by nature. +@MainActor open class Step { private let workflow: Workflow @@ -185,6 +187,7 @@ public extension ObservableType { /// - parameter workflow: The workflow this step belongs to. /// - returns: The newly forked step in the workflow. `nil` if this observable does not conform to the required /// generic type of (ActionableItemType, ValueType). + @MainActor func fork(_ workflow: Workflow) -> Step? { if let stepObservable = self as? Observable<(ActionableItemType, ValueType)> { workflow.didFork() From 2d73e738d46f66563b78f7dabfbe512ad00c1d27 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 10 Jan 2026 16:53:53 -0600 Subject: [PATCH 13/34] Add MainActor conformance to the view controllable and the presenter --- .../RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj | 2 ++ RIBs/Classes/Presenter.swift | 2 ++ RIBs/Classes/ViewControllable.swift | 1 + RIBs/Classes/ViewableRouter.swift | 3 ++- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index e8cc093..794b418 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -246,6 +246,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -275,6 +276,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/RIBs/Classes/Presenter.swift b/RIBs/Classes/Presenter.swift index edc1d4f..bf7a5f4 100644 --- a/RIBs/Classes/Presenter.swift +++ b/RIBs/Classes/Presenter.swift @@ -17,11 +17,13 @@ import Foundation /// The base protocol for all `Presenter`s. +@MainActor public protocol Presentable: AnyObject {} /// The base class of all `Presenter`s. A `Presenter` translates business models into values the corresponding /// `ViewController` can consume and display. It also maps UI events to business logic method, invoked to /// its listener. +@MainActor open class Presenter: Presentable { /// The view controller of this presenter. diff --git a/RIBs/Classes/ViewControllable.swift b/RIBs/Classes/ViewControllable.swift index 0e27040..211cf31 100644 --- a/RIBs/Classes/ViewControllable.swift +++ b/RIBs/Classes/ViewControllable.swift @@ -17,6 +17,7 @@ import UIKit /// Basic interface between a `Router` and the UIKit `UIViewController`. +@MainActor public protocol ViewControllable: AnyObject { var uiviewController: UIViewController { get } diff --git a/RIBs/Classes/ViewableRouter.swift b/RIBs/Classes/ViewableRouter.swift index 942b2c0..423ea1b 100644 --- a/RIBs/Classes/ViewableRouter.swift +++ b/RIBs/Classes/ViewableRouter.swift @@ -92,6 +92,7 @@ open class ViewableRouter: Router Date: Sat, 10 Jan 2026 17:35:39 -0600 Subject: [PATCH 14/34] make deinits isolated for swift strict concurrency --- RIBs/Classes/Interactor.swift | 12 +++++------ RIBs/Classes/PresentableInteractor.swift | 6 ++---- RIBs/Classes/Router.swift | 27 ++++++++++++------------ RIBs/Classes/ViewableRouter.swift | 5 ++--- 4 files changed, 22 insertions(+), 28 deletions(-) diff --git a/RIBs/Classes/Interactor.swift b/RIBs/Classes/Interactor.swift index 73cfc83..d123c79 100644 --- a/RIBs/Classes/Interactor.swift +++ b/RIBs/Classes/Interactor.swift @@ -142,13 +142,11 @@ open class Interactor: Interactable { private let isActiveSubject = BehaviorSubject(value: false) fileprivate var activenessDisposable: CompositeDisposable? - deinit { - // TODO: deal with this later - -// if isActive { -// deactivate() -// } -// isActiveSubject.onCompleted() + isolated deinit { + if isActive { + deactivate() + } + isActiveSubject.onCompleted() } } diff --git a/RIBs/Classes/PresentableInteractor.swift b/RIBs/Classes/PresentableInteractor.swift index ca30f16..38e9b98 100644 --- a/RIBs/Classes/PresentableInteractor.swift +++ b/RIBs/Classes/PresentableInteractor.swift @@ -34,9 +34,7 @@ open class PresentableInteractor: Interactor { // MARK: - Private - deinit { - // TODO: deal with this later - -// LeakDetector.instance.expectDeallocate(object: presenter as AnyObject) + isolated deinit { + LeakDetector.instance.expectDeallocate(object: presenter as AnyObject) } } diff --git a/RIBs/Classes/Router.swift b/RIBs/Classes/Router.swift index 6a36f29..1371702 100644 --- a/RIBs/Classes/Router.swift +++ b/RIBs/Classes/Router.swift @@ -214,20 +214,19 @@ open class Router: Routing { detachChild(child) } } - - deinit { - // TODO: get back to this + + isolated deinit { + interactable.deactivate() + + if !children.isEmpty { + detachAllChildren() + } + + lifecycleSubject.onCompleted() + + deinitDisposable.dispose() + + LeakDetector.instance.expectDeallocate(object: interactable) -// interactable.deactivate() -// -// if !children.isEmpty { -// detachAllChildren() -// } -// -// lifecycleSubject.onCompleted() -// -// deinitDisposable.dispose() -// -// LeakDetector.instance.expectDeallocate(object: interactable) } } diff --git a/RIBs/Classes/ViewableRouter.swift b/RIBs/Classes/ViewableRouter.swift index 423ea1b..e562cc3 100644 --- a/RIBs/Classes/ViewableRouter.swift +++ b/RIBs/Classes/ViewableRouter.swift @@ -91,8 +91,7 @@ open class ViewableRouter: Router Date: Sat, 10 Jan 2026 17:40:36 -0600 Subject: [PATCH 15/34] Make worker's deinit be isolated --- RIBs/Classes/Worker/Worker.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/RIBs/Classes/Worker/Worker.swift b/RIBs/Classes/Worker/Worker.swift index e05de40..ccd0963 100644 --- a/RIBs/Classes/Worker/Worker.swift +++ b/RIBs/Classes/Worker/Worker.swift @@ -169,12 +169,10 @@ open class Worker: Working { interactorBindingDisposable = nil } - deinit { - // TODO: deal with this later - -// stop() -// unbindInteractor() -// isStartedSubject.onCompleted() + isolated deinit { + stop() + unbindInteractor() + isStartedSubject.onCompleted() } } From 16a796ea9bea90def145c091b351d1ae46af52a1 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 10 Jan 2026 18:27:59 -0600 Subject: [PATCH 16/34] Mark leak detector and workflow helper methods as main actor --- .../RIBsAppExample2.xcodeproj/project.pbxproj | 6 ++++-- RIBs/Classes/LeakDetector/LeakDetector.swift | 1 + RIBs/Classes/Workflow/Workflow.swift | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index 794b418..9b05419 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -242,11 +242,12 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -272,11 +273,12 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/RIBs/Classes/LeakDetector/LeakDetector.swift b/RIBs/Classes/LeakDetector/LeakDetector.swift index 0f797aa..9cf3882 100644 --- a/RIBs/Classes/LeakDetector/LeakDetector.swift +++ b/RIBs/Classes/LeakDetector/LeakDetector.swift @@ -51,6 +51,7 @@ public protocol LeakDetectionHandle { /// A `Router` that owns an `Interactor` might for example expect its `Interactor` be deallocated when the `Router` /// itself is deallocated. If the interactor does not deallocate in time, a runtime assert is triggered, along with /// critical logging. +@MainActor public class LeakDetector { /// The singleton instance. diff --git a/RIBs/Classes/Workflow/Workflow.swift b/RIBs/Classes/Workflow/Workflow.swift index 29d8082..40f1e2a 100644 --- a/RIBs/Classes/Workflow/Workflow.swift +++ b/RIBs/Classes/Workflow/Workflow.swift @@ -209,6 +209,7 @@ public extension Disposable { /// - note: This is the preferred method when trying to confine a subscription to the lifecycle of a `Workflow`. /// /// - parameter workflow: The workflow to dispose the subscription with. + @MainActor func disposeWith(workflow: Workflow) { _ = workflow.compositeDisposable.insert(self) } @@ -223,6 +224,7 @@ public extension Disposable { /// /// - parameter workflow: The workflow to dispose the subscription with. @available(*, deprecated, renamed: "disposeWith(workflow:)") + @MainActor func disposeWith(worflow: Workflow) { disposeWith(workflow: worflow) } From f8f443d7368c97a8efce5689f3182577aa928fdb Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Mon, 16 Feb 2026 22:35:33 -0600 Subject: [PATCH 17/34] Add Rx service example, add actor service example, add worker example --- .../RIBsAppExample2.xcodeproj/project.pbxproj | 26 ++++++--- .../FirstViewableRIB/ActorService.swift | 25 +++++++++ .../FirstViewableRIBBuilder.swift | 10 +++- .../FirstViewableRIBInteractor.swift | 53 +++++++++++++++++-- .../FirstViewableRIB/RxSwiftService.swift | 36 +++++++++++++ .../SecondViewableRIB/ExampleWorker.swift | 28 ++++++++++ .../SecondViewableRIBBuilder.swift | 3 +- .../SecondViewableRIBInteractor.swift | 15 ++++-- .../SecondViewableRIBViewController.swift | 23 ++++++-- 9 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/ActorService.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/RxSwiftService.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/ExampleWorker.swift diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index 9b05419..fec87f3 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7E71E73D2F4426FA002FD889 /* RIBs */; }; 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EB78D4E2F12CC0000547345 /* RIBs */; }; + 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EFF41972F17D99D00B48704 /* RIBs */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,7 +58,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */, 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */, + 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -109,6 +113,8 @@ name = RIBsAppExample2; packageProductDependencies = ( 7EB78D4E2F12CC0000547345 /* RIBs */, + 7EFF41972F17D99D00B48704 /* RIBs */, + 7E71E73D2F4426FA002FD889 /* RIBs */, ); productName = RIBsAppExample2; productReference = 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */; @@ -166,7 +172,7 @@ mainGroup = 7EB78D102F12CB3800547345; minimizedProjectReferenceProxies = 1; packageReferences = ( - 7EB78D4D2F12CC0000547345 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, + 7E71E73C2F4426FA002FD889 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7EB78D1A2F12CB3800547345 /* Products */; @@ -247,8 +253,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -278,8 +284,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -476,17 +482,25 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 7EB78D4D2F12CC0000547345 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { + 7E71E73C2F4426FA002FD889 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../../../RIBs-iOS"; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 7E71E73D2F4426FA002FD889 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + productName = RIBs; + }; 7EB78D4E2F12CC0000547345 /* RIBs */ = { isa = XCSwiftPackageProductDependency; productName = RIBs; }; + 7EFF41972F17D99D00B48704 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + productName = RIBs; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7EB78D112F12CB3800547345 /* Project object */; diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/ActorService.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/ActorService.swift new file mode 100644 index 0000000..6bd66cd --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/ActorService.swift @@ -0,0 +1,25 @@ +// +// ActorService.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import Foundation + +protocol ActorServicable: Actor { + func doWork() async +} + +actor ActorService: ActorServicable { + func doWork() async { + printCurrentThread() + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + printCurrentThread() + } + + private func printCurrentThread() { + print("Running on: \(Thread.current)") + } +} + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift index 72f5702..ef7e5c6 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift @@ -14,6 +14,14 @@ protocol FirstViewableRIBDependency: Dependency { final class FirstViewableRIBComponent: Component, SecondViewableRIBDependency { + var actorService: ActorServicable { + ActorService() + } + + var rxSwiftService: RxSwiftServicable { + RxSwiftService() + } + var secondViewableRIBBuilder: SecondViewableRIBBuildable { SecondViewableRIBBuilder(dependency: self) } @@ -34,7 +42,7 @@ final class FirstViewableRIBBuilder: Builder, FirstV func build(withListener listener: FirstViewableRIBListener) -> FirstViewableRIBRouting { let component = FirstViewableRIBComponent(dependency: dependency) let viewController = FirstViewableRIBViewController() - let interactor = FirstViewableRIBInteractor(presenter: viewController) + let interactor = FirstViewableRIBInteractor(presenter: viewController, actorService: component.actorService, rxSwiftService: component.rxSwiftService) interactor.listener = listener return FirstViewableRIBRouter(interactor: interactor, viewController: viewController, secondViewableRIBBuilder: component.secondViewableRIBBuilder) } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift index 7c28b97..f021a7c 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift @@ -7,6 +7,7 @@ import RIBs import RxSwift +import Foundation protocol FirstViewableRIBRouting: ViewableRouting { var firstViewableRIBViewController: FirstViewableRIBViewControllable { get } @@ -27,10 +28,13 @@ final class FirstViewableRIBInteractor: PresentableInteractor .timer(.seconds(3), scheduler: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) .observe(on: MainScheduler.instance) - .subscribe(onSuccess: { [weak self] _ in - self?.router?.routeToSecondViewableRIB() + .subscribe(onSuccess: { _ in + self.printCurrentThread() + self.router?.routeToSecondViewableRIB() + self.printCurrentThread() }) .disposeOnDeactivate(interactor: self) + + rxSwiftService.doWork() + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { value in + self.printCurrentThread() + print("RxSwiftService.doWork() completed with value: \(value)") + self.printCurrentThread() + }) + .disposeOnDeactivate(interactor: self) + + Task { + await someAsyncWork() + } + + Task { + await someAsyncWork2() + } + } + + private func someAsyncWork() async { + printCurrentThread() + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + printCurrentThread() + } + + private func someAsyncWork2() async { + printCurrentThread() + await actorService.doWork() + printCurrentThread() + } + + private func printCurrentThread() { + print("Running on: \(Thread.current)") + } + + func didComplete(_ secondViewableRIB: SecondViewableRIBInteractable) { + router?.routeAwayFromSecondViewableRIB() } override func willResignActive() { diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/RxSwiftService.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/RxSwiftService.swift new file mode 100644 index 0000000..a637263 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/RxSwiftService.swift @@ -0,0 +1,36 @@ +// +// RxSwiftService.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import Foundation +import RxSwift + +protocol RxSwiftServicable { + func doWork() -> Single +} + +final class RxSwiftService: RxSwiftServicable { + private let backgroundScheduler = ConcurrentDispatchQueueScheduler(qos: .userInitiated) + + func doWork() -> Single { + return Single + .timer(.seconds(3), scheduler: backgroundScheduler) + .map { @Sendable _ in 42 } + +// return Single.create { @Sendable single in +// // Replace .main with .global() for background execution +// DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { +// // This closure now executes on a background thread +// print("Executing on thread: \(Thread.current)") +// single(.success(123)) +// } +// +// return Disposables.create() +// } +// .subscribe(on: backgroundScheduler) + } +} + diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/ExampleWorker.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/ExampleWorker.swift new file mode 100644 index 0000000..7355bc4 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/ExampleWorker.swift @@ -0,0 +1,28 @@ +// +// ExampleWorker.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import Foundation + +protocol ExampleWorker: Working {} + +final class ExampleWorkerImp: Worker, ExampleWorker { + + private let backgroundScheduler = ConcurrentDispatchQueueScheduler(qos: .userInitiated) + + override func didStart(_ interactorScope: InteractorScope) { + Single + .timer(.seconds(3), scheduler: backgroundScheduler) + .map { @Sendable _ in "mock response" } + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { value in + print("ExampleWorker: Single fired with value: \(value)") + }) + .disposeOnStop(self) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift index 486e885..afd683a 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift @@ -32,7 +32,8 @@ final class SecondViewableRIBBuilder: Builder, Seco func build(withListener listener: SecondViewableRIBListener) -> SecondViewableRIBRouting { let component = SecondViewableRIBComponent(dependency: dependency) let viewController = SecondViewableRIBViewController() - let interactor = SecondViewableRIBInteractor(presenter: viewController) + let exampleWorker = ExampleWorkerImp() + let interactor = SecondViewableRIBInteractor(presenter: viewController, exampleWorker: exampleWorker) interactor.listener = listener return SecondViewableRIBRouter(interactor: interactor, viewController: viewController) } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift index 9a68913..bd82d85 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift @@ -18,7 +18,7 @@ protocol SecondViewableRIBPresentable: Presentable { } protocol SecondViewableRIBListener: AnyObject { - // TODO: Declare methods the interactor can invoke to communicate with other RIBs. + func didComplete(_ secondViewableRIB: SecondViewableRIBInteractable) } final class SecondViewableRIBInteractor: PresentableInteractor, SecondViewableRIBInteractable, SecondViewableRIBPresentableListener { @@ -26,16 +26,21 @@ final class SecondViewableRIBInteractor: PresentableInteractor Date: Tue, 10 Mar 2026 14:37:48 -0500 Subject: [PATCH 18/34] Fix tests --- .github/workflows/iOS.yml | 4 ++-- RIBsTests/ComponentizedBuilderTests.swift | 1 + RIBsTests/DI/ComponentTests.swift | 1 + RIBsTests/Interactor/InteractorTests.swift | 6 +++--- RIBsTests/Interactor/PresentableInteractorTests.swift | 3 ++- RIBsTests/LaunchRouterTests.swift | 1 + RIBsTests/MultiStageComponentizedBuilderTests.swift | 1 + RIBsTests/Router/RouterTests.swift | 7 ++++--- RIBsTests/Router/ViewableRouterTests.swift | 3 ++- RIBsTests/Worker/WorkerTests.swift | 1 + RIBsTests/Workflow/WorkflowTests.swift | 1 + 11 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index d0929ad..f90091d 100644 --- a/.github/workflows/iOS.yml +++ b/.github/workflows/iOS.yml @@ -19,11 +19,11 @@ jobs: run: gem install cocoapods - name: Install pod dependencies - working-directory: ./Example + working-directory: ./Examples/Example1 run: pod install || pod install --repo-update - name: Run tests with coverage - working-directory: ./Example + working-directory: ./Examples/Example1 run: | xcodebuild \ -workspace RIBs.xcworkspace \ diff --git a/RIBsTests/ComponentizedBuilderTests.swift b/RIBsTests/ComponentizedBuilderTests.swift index d087729..af1e877 100644 --- a/RIBsTests/ComponentizedBuilderTests.swift +++ b/RIBsTests/ComponentizedBuilderTests.swift @@ -18,6 +18,7 @@ import XCTest import CwlPreconditionTesting +@MainActor class ComponentizedBuilderTests: XCTestCase { func test_componentForCurrentPass_builderReturnsSameInstance_verifyAssertion() { diff --git a/RIBsTests/DI/ComponentTests.swift b/RIBsTests/DI/ComponentTests.swift index 3e10878..b05a215 100644 --- a/RIBsTests/DI/ComponentTests.swift +++ b/RIBsTests/DI/ComponentTests.swift @@ -17,6 +17,7 @@ import XCTest @testable import RIBs +@MainActor final class ComponentTests: XCTestCase { // MARK: - Tests diff --git a/RIBsTests/Interactor/InteractorTests.swift b/RIBsTests/Interactor/InteractorTests.swift index 5797869..11e5f0a 100644 --- a/RIBsTests/Interactor/InteractorTests.swift +++ b/RIBsTests/Interactor/InteractorTests.swift @@ -9,6 +9,7 @@ import XCTest import RxSwift +@MainActor final class InteractorTests: XCTestCase { private var interactor: InteractorMock! @@ -87,7 +88,7 @@ final class InteractorTests: XCTestCase { XCTAssertEqual(interactor.willResignActiveCallCount, 1) } - func test_isActiveStream_completedOnInteractorDeinit() { + func test_isActiveStream_completedOnInteractorDeinit() async { // given var isActiveStreamCompleted = false interactor.activate() @@ -99,7 +100,6 @@ final class InteractorTests: XCTestCase { interactor = nil // then XCTAssertTrue(isActiveStreamCompleted) - } // MARK: - BEGIN Observables Attached/Detached to/from Interactor @@ -131,7 +131,7 @@ final class InteractorTests: XCTestCase { XCTAssertTrue(onDisposeCalled) } - func test_observableIsDisposedOnInteractorDeinit() { + func test_observableIsDisposedOnInteractorDeinit() async { // given var onDisposeCalled = false let subjectEmiitingValues: PublishSubject = .init() diff --git a/RIBsTests/Interactor/PresentableInteractorTests.swift b/RIBsTests/Interactor/PresentableInteractorTests.swift index 7505703..76bac6f 100644 --- a/RIBsTests/Interactor/PresentableInteractorTests.swift +++ b/RIBsTests/Interactor/PresentableInteractorTests.swift @@ -13,6 +13,7 @@ protocol TestPresenter {} final class PresenterMock: TestPresenter {} +@MainActor final class PresentableInteractorTests: XCTestCase { private var interactor: PresentableInteractor! @@ -22,7 +23,7 @@ final class PresentableInteractorTests: XCTestCase { } - func test_deinit_doesNotLeakPresenter() { + func test_deinit_doesNotLeakPresenter() async { // given let presenterMock = PresenterMock() let disposeBag = DisposeBag() diff --git a/RIBsTests/LaunchRouterTests.swift b/RIBsTests/LaunchRouterTests.swift index dba33ae..c8d311d 100644 --- a/RIBsTests/LaunchRouterTests.swift +++ b/RIBsTests/LaunchRouterTests.swift @@ -17,6 +17,7 @@ @testable import RIBs import XCTest +@MainActor final class LaunchRouterTests: XCTestCase { private var launchRouter: LaunchRouting! diff --git a/RIBsTests/MultiStageComponentizedBuilderTests.swift b/RIBsTests/MultiStageComponentizedBuilderTests.swift index 917005f..c23a9de 100644 --- a/RIBsTests/MultiStageComponentizedBuilderTests.swift +++ b/RIBsTests/MultiStageComponentizedBuilderTests.swift @@ -18,6 +18,7 @@ import XCTest import CwlPreconditionTesting +@MainActor class MultiStageComponentizedBuilderTests: XCTestCase { private var builder: MockMultiStageComponentizedBuilder! diff --git a/RIBsTests/Router/RouterTests.swift b/RIBsTests/Router/RouterTests.swift index 2ab8a20..f99c288 100644 --- a/RIBsTests/Router/RouterTests.swift +++ b/RIBsTests/Router/RouterTests.swift @@ -49,6 +49,7 @@ final class RouterMock: Routing { } } +@MainActor final class RouterTests: XCTestCase { private var router: Router! @@ -73,7 +74,7 @@ final class RouterTests: XCTestCase { // MARK: - Tests - func test_load_verifyLifecycleObservable() { + func test_load_verifyLifecycleObservable() async { router = Router(interactor: InteractableMock()) var currentLifecycle: RouterLifecycle? var didComplete = false @@ -148,7 +149,7 @@ final class RouterTests: XCTestCase { XCTAssertEqual(mockChildInteractor.deactivateCallCount, 1) } - func test_detachChild_deactivatesSubtreeOfTheChild() { + func test_detachChild_deactivatesSubtreeOfTheChild() async { // given router = Router(interactor: InteractableMock()) let childInteractor = Interactor() @@ -167,7 +168,7 @@ final class RouterTests: XCTestCase { XCTAssertEqual(grandChildInteractor.deactivateCallCount, 1) } - func test_deinit_triggers_leakDetection() { + func test_deinit_triggers_leakDetection() async { // given let interactor = InteractableMock() router = Router(interactor: interactor) diff --git a/RIBsTests/Router/ViewableRouterTests.swift b/RIBsTests/Router/ViewableRouterTests.swift index 88d3f0e..36ea8f0 100644 --- a/RIBsTests/Router/ViewableRouterTests.swift +++ b/RIBsTests/Router/ViewableRouterTests.swift @@ -18,6 +18,7 @@ final class ViewControllerMock: ViewControllable { } } +@MainActor final class ViewableRouterTests: XCTestCase { private var router: ViewableRouter, ViewControllerMock>! @@ -42,7 +43,7 @@ final class ViewableRouterTests: XCTestCase { XCTAssertEqual(leakDetectorMock.expectViewControllerDisappearCallCount, 1) } - func test_deinit_triggers_leakDetection() { + func test_deinit_triggers_leakDetection() async { // given let interactor = PresentableInteractor(presenter: PresenterMock()) let viewController = ViewControllerMock() diff --git a/RIBsTests/Worker/WorkerTests.swift b/RIBsTests/Worker/WorkerTests.swift index ed86e10..5ac2d53 100644 --- a/RIBsTests/Worker/WorkerTests.swift +++ b/RIBsTests/Worker/WorkerTests.swift @@ -18,6 +18,7 @@ import XCTest import RxSwift @testable import RIBs +@MainActor final class WorkerTests: XCTestCase { private var worker: TestWorker! diff --git a/RIBsTests/Workflow/WorkflowTests.swift b/RIBsTests/Workflow/WorkflowTests.swift index ccfb812..925f9e7 100644 --- a/RIBsTests/Workflow/WorkflowTests.swift +++ b/RIBsTests/Workflow/WorkflowTests.swift @@ -18,6 +18,7 @@ import XCTest import RxSwift @testable import RIBs +@MainActor final class WorkerflowTests: XCTestCase { func test_nestedStepsDoNotRepeat() { From 3d69a300ab98dcd2a54aa2823aee16f0325181e5 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 14:39:52 -0500 Subject: [PATCH 19/34] Fix CI --- Examples/Example1/Podfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/Example1/Podfile b/Examples/Example1/Podfile index 6c660d9..d3fed94 100644 --- a/Examples/Example1/Podfile +++ b/Examples/Example1/Podfile @@ -3,6 +3,6 @@ use_frameworks! platform :ios, '15.0' target 'RIBs_Example' do - pod 'RIBs', :path => '../', :testspecs => ['Tests'] + pod 'RIBs', :path => '../../', :testspecs => ['Tests'] end From 104051051c0d8ac547154ef6793d17dea695c725 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 14:53:44 -0500 Subject: [PATCH 20/34] Fix CI --- .github/workflows/iOS.yml | 2 +- Examples/Example1/Podfile | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index f90091d..9a75c00 100644 --- a/.github/workflows/iOS.yml +++ b/.github/workflows/iOS.yml @@ -29,7 +29,7 @@ jobs: -workspace RIBs.xcworkspace \ -scheme RIBs-Example \ -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'generic/platform=iOS Simulator' \ -enableCodeCoverage YES \ clean test diff --git a/Examples/Example1/Podfile b/Examples/Example1/Podfile index d3fed94..07090ee 100644 --- a/Examples/Example1/Podfile +++ b/Examples/Example1/Podfile @@ -6,3 +6,11 @@ target 'RIBs_Example' do pod 'RIBs', :path => '../../', :testspecs => ['Tests'] end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end +end From 22ac1b7beeb45e0802ff67b4a025ca1c9b5f14e4 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 14:57:20 -0500 Subject: [PATCH 21/34] Fix CI --- .github/workflows/iOS.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index 9a75c00..9faeeb0 100644 --- a/.github/workflows/iOS.yml +++ b/.github/workflows/iOS.yml @@ -29,7 +29,7 @@ jobs: -workspace RIBs.xcworkspace \ -scheme RIBs-Example \ -sdk iphonesimulator \ - -destination 'generic/platform=iOS Simulator' \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.0' \ -enableCodeCoverage YES \ clean test From eae2d1555b0cb6d1ee20771ee540dcf45657e021 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 14:59:52 -0500 Subject: [PATCH 22/34] Fix CI --- .github/workflows/iOS.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index 9faeeb0..d4f10b5 100644 --- a/.github/workflows/iOS.yml +++ b/.github/workflows/iOS.yml @@ -29,7 +29,7 @@ jobs: -workspace RIBs.xcworkspace \ -scheme RIBs-Example \ -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.0' \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.5' \ -enableCodeCoverage YES \ clean test From a23806364a015b5ce66a0e63917c3a09419b4f37 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 15:04:51 -0500 Subject: [PATCH 23/34] Fix CI --- RIBs.podspec | 1 + 1 file changed, 1 insertion(+) diff --git a/RIBs.podspec b/RIBs.podspec index 3f41d28..a25bdb1 100644 --- a/RIBs.podspec +++ b/RIBs.podspec @@ -11,6 +11,7 @@ RIBs is the cross-platform architecture behind many mobile apps at Uber. This ar s.source = { :git => 'https://github.com/uber/RIBs-iOS.git', :tag => s.version.to_s } s.ios.deployment_target = '15.0' s.swift_version = '5.0' + s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '$(inherited) -enable-experimental-feature IsolatedDeinit' } s.source_files = 'RIBs/Classes/**/*' s.dependency 'RxSwift', '~> 6.9.0' s.dependency 'RxRelay', '~> 6.9.0' From d745e5ee990c68f7e8b5bb1f7044c56f4ddc79a2 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 15:10:31 -0500 Subject: [PATCH 24/34] Fix CI --- .github/workflows/iOS.yml | 5 ++++- RIBs.podspec | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index d4f10b5..52d822d 100644 --- a/.github/workflows/iOS.yml +++ b/.github/workflows/iOS.yml @@ -4,12 +4,15 @@ on: [push, pull_request] jobs: build-and-test: - runs-on: macos-latest + runs-on: macos-15 steps: - name: Checkout code uses: actions/checkout@v3 + - name: Select Xcode 26.2 (Swift 6.2 — isolated deinit supported) + run: sudo xcode-select -s /Applications/Xcode_26.2.app + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/RIBs.podspec b/RIBs.podspec index a25bdb1..3f41d28 100644 --- a/RIBs.podspec +++ b/RIBs.podspec @@ -11,7 +11,6 @@ RIBs is the cross-platform architecture behind many mobile apps at Uber. This ar s.source = { :git => 'https://github.com/uber/RIBs-iOS.git', :tag => s.version.to_s } s.ios.deployment_target = '15.0' s.swift_version = '5.0' - s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '$(inherited) -enable-experimental-feature IsolatedDeinit' } s.source_files = 'RIBs/Classes/**/*' s.dependency 'RxSwift', '~> 6.9.0' s.dependency 'RxRelay', '~> 6.9.0' From 7d1f1de6b5b0e3f8f277fac9c68427b2f7aa620d Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 10 Mar 2026 15:42:15 -0500 Subject: [PATCH 25/34] Add headless RIB example --- .../SecondViewableRIBBuilder.swift | 18 +++++-- .../SecondViewableRIBInteractor.swift | 7 ++- .../SecondViewableRIBRouter.swift | 23 +++++++-- .../ThirdHeadlessRIBBuilder.swift | 44 ++++++++++++++++ .../ThirdHeadlessRIBInteractor.swift | 51 +++++++++++++++++++ .../ThirdHeadlessRIBRouter.swift | 38 ++++++++++++++ 6 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift index afd683a..9501c9c 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift @@ -12,9 +12,19 @@ protocol SecondViewableRIBDependency: Dependency { // created by this RIB. } -final class SecondViewableRIBComponent: Component { +final class SecondViewableRIBComponent: Component, ThirdHeadlessRIBDependency { - // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. + var thirdHeadlessRIBBuilder: ThirdHeadlessRIBBuildable { + ThirdHeadlessRIBBuilder(dependency: self) + } + + var viewController: SecondViewableRIBPresentable & SecondViewableRIBViewControllable { + SecondViewableRIBViewController() + } + + var thirdHeadlessRIBViewController: any ThirdHeadlessRIBViewControllable { + viewController + } } // MARK: - Builder @@ -31,10 +41,10 @@ final class SecondViewableRIBBuilder: Builder, Seco func build(withListener listener: SecondViewableRIBListener) -> SecondViewableRIBRouting { let component = SecondViewableRIBComponent(dependency: dependency) - let viewController = SecondViewableRIBViewController() + let viewController = component.viewController let exampleWorker = ExampleWorkerImp() let interactor = SecondViewableRIBInteractor(presenter: viewController, exampleWorker: exampleWorker) interactor.listener = listener - return SecondViewableRIBRouter(interactor: interactor, viewController: viewController) + return SecondViewableRIBRouter(interactor: interactor, viewController: viewController, thirdHeadlessRIBBuilder: component.thirdHeadlessRIBBuilder) } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift index bd82d85..eb00755 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift @@ -45,6 +45,11 @@ final class SecondViewableRIBInteractor: PresentableInteractor, SecondViewableRIBRouting { + + private let thirdHeadlessRIBBuilder: ThirdHeadlessRIBBuildable + private var thirdHeadlessRIBRouter: ThirdHeadlessRIBRouting? - // TODO: Constructor inject child builder protocols to allow building children. - override init(interactor: SecondViewableRIBInteractable, viewController: SecondViewableRIBViewControllable) { + init(interactor: SecondViewableRIBInteractable, viewController: SecondViewableRIBViewControllable, thirdHeadlessRIBBuilder: ThirdHeadlessRIBBuildable) { + self.thirdHeadlessRIBBuilder = thirdHeadlessRIBBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } @@ -27,4 +30,16 @@ final class SecondViewableRIBRouter: ViewableRouter { + + fileprivate var thirdHeadlessRIBViewController: ThirdHeadlessRIBViewControllable { + return dependency.thirdHeadlessRIBViewController + } + + // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. +} + +// MARK: - Builder + +protocol ThirdHeadlessRIBBuildable: Buildable { + func build(withListener listener: ThirdHeadlessRIBListener) -> ThirdHeadlessRIBRouting +} + +final class ThirdHeadlessRIBBuilder: Builder, ThirdHeadlessRIBBuildable { + + override init(dependency: ThirdHeadlessRIBDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: ThirdHeadlessRIBListener) -> ThirdHeadlessRIBRouting { + let component = ThirdHeadlessRIBComponent(dependency: dependency) + let interactor = ThirdHeadlessRIBInteractor() + interactor.listener = listener + return ThirdHeadlessRIBRouter(interactor: interactor, viewController: component.thirdHeadlessRIBViewController) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift new file mode 100644 index 0000000..deeaffb --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift @@ -0,0 +1,51 @@ +// +// ThirdHeadlessRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs +import RxSwift + +protocol ThirdHeadlessRIBRouting: Routing { + func cleanupViews() + // TODO: Declare methods the interactor can invoke to manage sub-tree via the router. +} + +protocol ThirdHeadlessRIBListener: AnyObject { + func sendData(_ interactor: ThirdHeadlessRIBInteractable) +} + +final class ThirdHeadlessRIBInteractor: Interactor, ThirdHeadlessRIBInteractable { + + private let backgroundScheduler = ConcurrentDispatchQueueScheduler(qos: .userInitiated) + + weak var router: ThirdHeadlessRIBRouting? + weak var listener: ThirdHeadlessRIBListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init() {} + + override func didBecomeActive() { + super.didBecomeActive() + + print("do some work in the ThirdHeadlessRIBInteractor") + + Single + .timer(.seconds(3), scheduler: backgroundScheduler) + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { _ in + self.listener?.sendData(self) + }) + .disposeOnDeactivate(interactor: self) + } + + override func willResignActive() { + super.willResignActive() + + router?.cleanupViews() + // TODO: Pause any business logic. + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift new file mode 100644 index 0000000..f13f5af --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift @@ -0,0 +1,38 @@ +// +// ThirdHeadlessRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs + +protocol ThirdHeadlessRIBInteractable: Interactable { + var router: ThirdHeadlessRIBRouting? { get set } + var listener: ThirdHeadlessRIBListener? { get set } +} + +protocol ThirdHeadlessRIBViewControllable: ViewControllable { + // TODO: Declare methods the router invokes to manipulate the view hierarchy. Since + // this RIB does not own its own view, this protocol is conformed to by one of this + // RIB's ancestor RIBs' view. +} + +final class ThirdHeadlessRIBRouter: Router, ThirdHeadlessRIBRouting { + + // TODO: Constructor inject child builder protocols to allow building children. + init(interactor: ThirdHeadlessRIBInteractable, viewController: ThirdHeadlessRIBViewControllable) { + self.viewController = viewController + super.init(interactor: interactor) + interactor.router = self + } + + func cleanupViews() { + // TODO: Since this router does not own its view, it needs to cleanup the views + // it may have added to the view hierarchy, when its interactor is deactivated. + } + + // MARK: - Private + + private let viewController: ThirdHeadlessRIBViewControllable +} From 6f0bd36154b6df1afd0ca47b0b393aad5bd16613 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Wed, 11 Mar 2026 13:38:17 -0500 Subject: [PATCH 26/34] Add presenter example --- .../FourthViewableRIBBuilder.swift | 40 ++++++++++++++ .../FourthViewableRIBInteractor.swift | 55 +++++++++++++++++++ .../FourthViewableRIBPresenter.swift | 23 ++++++++ .../FourthViewableRIBRouter.swift | 26 +++++++++ .../FourthViewableRIBViewController.swift | 29 ++++++++++ .../SecondViewableRIBBuilder.swift | 2 +- .../ThirdHeadlessRIBBuilder.swift | 8 ++- .../ThirdHeadlessRIBInteractor.swift | 3 + .../ThirdHeadlessRIBRouter.swift | 30 ++++++++-- 9 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBPresenter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBViewController.swift diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift new file mode 100644 index 0000000..41f39aa --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift @@ -0,0 +1,40 @@ +// +// FourthViewableRIBBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs + +protocol FourthViewableRIBDependency: Dependency { + // TODO: Declare the set of dependencies required by this RIB, but cannot be + // created by this RIB. +} + +final class FourthViewableRIBComponent: Component { + + // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. +} + +// MARK: - Builder + +protocol FourthViewableRIBBuildable: Buildable { + func build(withListener listener: FourthViewableRIBListener) -> FourthViewableRIBRouting +} + +final class FourthViewableRIBBuilder: Builder, FourthViewableRIBBuildable { + + override init(dependency: FourthViewableRIBDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: FourthViewableRIBListener) -> FourthViewableRIBRouting { + let component = FourthViewableRIBComponent(dependency: dependency) + let viewController = FourthViewableRIBViewController() + let presenter = FourthViewableRIBPresenter(viewController: viewController) + let interactor = FourthViewableRIBInteractor(presenter: presenter) + interactor.listener = listener + return FourthViewableRIBRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBInteractor.swift new file mode 100644 index 0000000..b0150b7 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBInteractor.swift @@ -0,0 +1,55 @@ +// +// FourthViewableRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs +import RxSwift + +protocol FourthViewableRIBRouting: ViewableRouting { + // TODO: Declare methods the interactor can invoke to manage sub-tree via the router. +} + +protocol FourthViewableRIBPresentable: Presentable { + var listener: FourthViewableRIBPresentableListener? { get set } + + func presentSomeStuff() +} + +protocol FourthViewableRIBListener: AnyObject { + // TODO: Declare methods the interactor can invoke to communicate with other RIBs. +} + +final class FourthViewableRIBInteractor: PresentableInteractor, FourthViewableRIBInteractable, FourthViewableRIBPresentableListener { + + weak var router: FourthViewableRIBRouting? + weak var listener: FourthViewableRIBListener? + + private let backgroundScheduler = ConcurrentDispatchQueueScheduler(qos: .userInitiated) + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: FourthViewableRIBPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + Single + .timer(.seconds(4), scheduler: backgroundScheduler) + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { _ in + self.presenter.presentSomeStuff() + }) + .disposeOnDeactivate(interactor: self) + } + + override func willResignActive() { + super.willResignActive() + // TODO: Pause any business logic. + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBPresenter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBPresenter.swift new file mode 100644 index 0000000..7c4d99e --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBPresenter.swift @@ -0,0 +1,23 @@ +// +// FourthViewableRIBPresenter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs + +protocol FourthViewableRIBPresentableListener: AnyObject { + // TODO: Declare properties and methods that the view controller can invoke to perform + // business logic, such as signIn(). This protocol is implemented by the corresponding + // interactor class. +} + +final class FourthViewableRIBPresenter: Presenter, FourthViewableRIBPresentable { + + weak var listener: FourthViewableRIBPresentableListener? + + func presentSomeStuff() { + viewController.renderSomeOtherColor() + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift new file mode 100644 index 0000000..28e4154 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift @@ -0,0 +1,26 @@ +// +// FourthViewableRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs + +protocol FourthViewableRIBInteractable: Interactable { + var router: FourthViewableRIBRouting? { get set } + var listener: FourthViewableRIBListener? { get set } +} + +protocol FourthViewableRIBViewControllable: ViewControllable { + func renderSomeOtherColor() +} + +final class FourthViewableRIBRouter: ViewableRouter, FourthViewableRIBRouting { + + // TODO: Constructor inject child builder protocols to allow building children. + override init(interactor: FourthViewableRIBInteractable, viewController: FourthViewableRIBViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBViewController.swift new file mode 100644 index 0000000..6a47376 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBViewController.swift @@ -0,0 +1,29 @@ +// +// FourthViewableRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs +import RxSwift +import UIKit + +protocol FourthViewableRIBViewControllableDelegate: AnyObject { + +} + +final class FourthViewableRIBViewController: UIViewController, FourthViewableRIBViewControllable { + + weak var delegate: FourthViewableRIBViewControllableDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemRed + } + + func renderSomeOtherColor() { + view.backgroundColor = .systemPurple + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift index 9501c9c..c2eea8e 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift @@ -19,7 +19,7 @@ final class SecondViewableRIBComponent: Component, } var viewController: SecondViewableRIBPresentable & SecondViewableRIBViewControllable { - SecondViewableRIBViewController() + shared { SecondViewableRIBViewController() } } var thirdHeadlessRIBViewController: any ThirdHeadlessRIBViewControllable { diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift index be613db..02049f9 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift @@ -14,13 +14,15 @@ protocol ThirdHeadlessRIBDependency: Dependency { // created by this RIB. } -final class ThirdHeadlessRIBComponent: Component { +final class ThirdHeadlessRIBComponent: Component, FourthViewableRIBDependency { fileprivate var thirdHeadlessRIBViewController: ThirdHeadlessRIBViewControllable { return dependency.thirdHeadlessRIBViewController } - // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. + fileprivate var fourthViewableRIBBuilder: FourthViewableRIBBuildable { + FourthViewableRIBBuilder(dependency: self) + } } // MARK: - Builder @@ -39,6 +41,6 @@ final class ThirdHeadlessRIBBuilder: Builder, ThirdH let component = ThirdHeadlessRIBComponent(dependency: dependency) let interactor = ThirdHeadlessRIBInteractor() interactor.listener = listener - return ThirdHeadlessRIBRouter(interactor: interactor, viewController: component.thirdHeadlessRIBViewController) + return ThirdHeadlessRIBRouter(interactor: interactor, viewController: component.thirdHeadlessRIBViewController, fourthViewableRIBBuilder: component.fourthViewableRIBBuilder) } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift index deeaffb..4c00751 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift @@ -11,6 +11,8 @@ import RxSwift protocol ThirdHeadlessRIBRouting: Routing { func cleanupViews() // TODO: Declare methods the interactor can invoke to manage sub-tree via the router. + func routeToFourthRIB() + func routeAwayFromFourthRIB() } protocol ThirdHeadlessRIBListener: AnyObject { @@ -38,6 +40,7 @@ final class ThirdHeadlessRIBInteractor: Interactor, ThirdHeadlessRIBInteractable .observe(on: MainScheduler.instance) .subscribe(onSuccess: { _ in self.listener?.sendData(self) + self.router?.routeToFourthRIB() }) .disposeOnDeactivate(interactor: self) } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift index f13f5af..eed824a 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift @@ -7,7 +7,7 @@ import RIBs -protocol ThirdHeadlessRIBInteractable: Interactable { +protocol ThirdHeadlessRIBInteractable: Interactable, FourthViewableRIBListener { var router: ThirdHeadlessRIBRouting? { get set } var listener: ThirdHeadlessRIBListener? { get set } } @@ -19,10 +19,16 @@ protocol ThirdHeadlessRIBViewControllable: ViewControllable { } final class ThirdHeadlessRIBRouter: Router, ThirdHeadlessRIBRouting { + + private let viewController: ThirdHeadlessRIBViewControllable + + private let fourthViewableRIBBuilder: FourthViewableRIBBuildable + private var fourthViewableRIBRouter: FourthViewableRIBRouting? // TODO: Constructor inject child builder protocols to allow building children. - init(interactor: ThirdHeadlessRIBInteractable, viewController: ThirdHeadlessRIBViewControllable) { + init(interactor: ThirdHeadlessRIBInteractable, viewController: ThirdHeadlessRIBViewControllable, fourthViewableRIBBuilder: FourthViewableRIBBuildable) { self.viewController = viewController + self.fourthViewableRIBBuilder = fourthViewableRIBBuilder super.init(interactor: interactor) interactor.router = self } @@ -30,9 +36,23 @@ final class ThirdHeadlessRIBRouter: Router, ThirdH func cleanupViews() { // TODO: Since this router does not own its view, it needs to cleanup the views // it may have added to the view hierarchy, when its interactor is deactivated. + routeAwayFromFourthRIB() } - // MARK: - Private - - private let viewController: ThirdHeadlessRIBViewControllable + + func routeToFourthRIB() { + let fourthViewableRIBRouter = fourthViewableRIBBuilder.build(withListener: interactor) + self.fourthViewableRIBRouter = fourthViewableRIBRouter + let fourthViewableRIBViewControllable = fourthViewableRIBRouter.viewControllable + attachChild(fourthViewableRIBRouter) + viewController.uiviewController.navigationController?.pushViewController(fourthViewableRIBViewControllable.uiviewController, animated: true) + } + + func routeAwayFromFourthRIB() { + if let fourthViewableRIBRouter = fourthViewableRIBRouter { + self.fourthViewableRIBRouter = nil + viewController.uiviewController.navigationController?.popViewController(animated: true) + detachChild(fourthViewableRIBRouter) + } + } } From 580da97fd857e2346c5c7a2a9075861691fedc39 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Wed, 11 Mar 2026 17:32:30 -0500 Subject: [PATCH 27/34] Add workflow example --- .../RIBsAppExample2.xcodeproj/project.pbxproj | 36 +++++++++----- .../FirstViewableRIBBuilder.swift | 19 +++++--- .../FirstViewableRIBInteractor.swift | 12 +++++ .../FirstViewableRIBRouter.swift | 24 +++++++++- .../FourthViewableRIBBuilder.swift | 7 +-- .../FourthViewableRIBRouter.swift | 2 +- .../RIBsAppExample2/Info.plist | 11 +++++ .../RIBsAppExample2/Root/RootBuilder.swift | 14 ++++-- .../RIBsAppExample2/Root/RootInteractor.swift | 31 ++++++++++-- .../RIBsAppExample2/Root/RootRouter.swift | 13 ++--- .../RIBsAppExample2/SceneDelegate.swift | 24 ++++++---- .../ThirdHeadlessRIBRouter.swift | 2 +- .../OpenFourthViewableRIBWorkflow.swift | 47 +++++++++++++++++++ .../Workflows/UrlHandler.swift | 12 +++++ 14 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Workflows/OpenFourthViewableRIBWorkflow.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/Workflows/UrlHandler.swift diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index fec87f3..b62adf5 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7E71E73D2F4426FA002FD889 /* RIBs */; }; + 7EA3A6CE2F61EE4A00D01810 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EA3A6CD2F61EE4A00D01810 /* RIBs */; }; 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EB78D4E2F12CC0000547345 /* RIBs */; }; 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EFF41972F17D99D00B48704 /* RIBs */; }; /* End PBXBuildFile section */ @@ -58,6 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7EA3A6CE2F61EE4A00D01810 /* RIBs in Frameworks */, 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */, 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */, 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */, @@ -115,6 +117,7 @@ 7EB78D4E2F12CC0000547345 /* RIBs */, 7EFF41972F17D99D00B48704 /* RIBs */, 7E71E73D2F4426FA002FD889 /* RIBs */, + 7EA3A6CD2F61EE4A00D01810 /* RIBs */, ); productName = RIBsAppExample2; productReference = 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */; @@ -172,7 +175,7 @@ mainGroup = 7EB78D102F12CB3800547345; minimizedProjectReferenceProxies = 1; packageReferences = ( - 7E71E73C2F4426FA002FD889 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, + 7EA3A6CC2F61EE4A00D01810 /* XCRemoteSwiftPackageReference "RIBs-iOS" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7EB78D1A2F12CB3800547345 /* Products */; @@ -251,10 +254,10 @@ OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -282,10 +285,10 @@ OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -481,18 +484,27 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 7E71E73C2F4426FA002FD889 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "../../../RIBs-iOS"; +/* Begin XCRemoteSwiftPackageReference section */ + 7EA3A6CC2F61EE4A00D01810 /* XCRemoteSwiftPackageReference "RIBs-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uber/RIBs-iOS.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; }; -/* End XCLocalSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 7E71E73D2F4426FA002FD889 /* RIBs */ = { isa = XCSwiftPackageProductDependency; productName = RIBs; }; + 7EA3A6CD2F61EE4A00D01810 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + package = 7EA3A6CC2F61EE4A00D01810 /* XCRemoteSwiftPackageReference "RIBs-iOS" */; + productName = RIBs; + }; 7EB78D4E2F12CC0000547345 /* RIBs */ = { isa = XCSwiftPackageProductDependency; productName = RIBs; diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift index ef7e5c6..b265a28 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift @@ -12,25 +12,29 @@ protocol FirstViewableRIBDependency: Dependency { // created by this RIB. } -final class FirstViewableRIBComponent: Component, SecondViewableRIBDependency { - +final class FirstViewableRIBComponent: Component, SecondViewableRIBDependency, FourthViewableRIBDependency { + var actorService: ActorServicable { ActorService() } - + var rxSwiftService: RxSwiftServicable { RxSwiftService() } - + var secondViewableRIBBuilder: SecondViewableRIBBuildable { SecondViewableRIBBuilder(dependency: self) } + + var fourthViewableRIBBuilder: FourthViewableRIBBuildable { + FourthViewableRIBBuilder(dependency: self) + } } // MARK: - Builder protocol FirstViewableRIBBuildable: Buildable { - func build(withListener listener: FirstViewableRIBListener) -> FirstViewableRIBRouting + func build(withListener listener: FirstViewableRIBListener) -> (routing: FirstViewableRIBRouting, actionableItem: FirstViewableRIBActionableItem) } final class FirstViewableRIBBuilder: Builder, FirstViewableRIBBuildable { @@ -39,11 +43,12 @@ final class FirstViewableRIBBuilder: Builder, FirstV super.init(dependency: dependency) } - func build(withListener listener: FirstViewableRIBListener) -> FirstViewableRIBRouting { + func build(withListener listener: FirstViewableRIBListener) -> (routing: FirstViewableRIBRouting, actionableItem: FirstViewableRIBActionableItem) { let component = FirstViewableRIBComponent(dependency: dependency) let viewController = FirstViewableRIBViewController() let interactor = FirstViewableRIBInteractor(presenter: viewController, actorService: component.actorService, rxSwiftService: component.rxSwiftService) interactor.listener = listener - return FirstViewableRIBRouter(interactor: interactor, viewController: viewController, secondViewableRIBBuilder: component.secondViewableRIBBuilder) + let router = FirstViewableRIBRouter(interactor: interactor, viewController: viewController, secondViewableRIBBuilder: component.secondViewableRIBBuilder, fourthViewableRIBBuilder: component.fourthViewableRIBBuilder) + return (routing: router, actionableItem: interactor) } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift index f021a7c..efa1bb4 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift @@ -9,10 +9,13 @@ import RIBs import RxSwift import Foundation + protocol FirstViewableRIBRouting: ViewableRouting { var firstViewableRIBViewController: FirstViewableRIBViewControllable { get } func routeToSecondViewableRIB() func routeAwayFromSecondViewableRIB() + func routeToFourthViewableRIB() -> FourthViewableRIBActionableItem + func routeAwayFromFourthViewableRIB() } protocol FirstViewableRIBPresentable: Presentable { @@ -90,6 +93,15 @@ final class FirstViewableRIBInteractor: PresentableInteractor Observable<(FourthViewableRIBActionableItem, ())> { + guard let fourthItem = router?.routeToFourthViewableRIB() else { + return .empty() + } + return .just((fourthItem, ())) + } + override func willResignActive() { super.willResignActive() // TODO: Pause any business logic. diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift index 20f3ee4..d9b0fa1 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift @@ -7,7 +7,7 @@ import RIBs -protocol FirstViewableRIBInteractable: Interactable, SecondViewableRIBListener { +protocol FirstViewableRIBInteractable: Interactable, FirstViewableRIBActionableItem, SecondViewableRIBListener, FourthViewableRIBListener { var router: FirstViewableRIBRouting? { get set } var listener: FirstViewableRIBListener? { get set } } @@ -21,8 +21,12 @@ final class FirstViewableRIBRouter: ViewableRouter FourthViewableRIBActionableItem { + let (fourthViewableRIBRouter, actionableItem) = fourthViewableRIBBuilder.build(withListener: interactor) + self.fourthViewableRIBRouter = fourthViewableRIBRouter + attachChild(fourthViewableRIBRouter) + viewController.uiviewController.navigationController?.pushViewController(fourthViewableRIBRouter.viewControllable.uiviewController, animated: true) + return actionableItem + } + + func routeAwayFromFourthViewableRIB() { + if let fourthViewableRIBRouter = fourthViewableRIBRouter { + self.fourthViewableRIBRouter = nil + viewController.uiviewController.navigationController?.popToViewController(viewController.uiviewController, animated: true) + detachChild(fourthViewableRIBRouter) + } + } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift index 41f39aa..6cfb884 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift @@ -20,7 +20,7 @@ final class FourthViewableRIBComponent: Component { // MARK: - Builder protocol FourthViewableRIBBuildable: Buildable { - func build(withListener listener: FourthViewableRIBListener) -> FourthViewableRIBRouting + func build(withListener listener: FourthViewableRIBListener) -> (routing: FourthViewableRIBRouting, actionableItem: FourthViewableRIBActionableItem) } final class FourthViewableRIBBuilder: Builder, FourthViewableRIBBuildable { @@ -29,12 +29,13 @@ final class FourthViewableRIBBuilder: Builder, Four super.init(dependency: dependency) } - func build(withListener listener: FourthViewableRIBListener) -> FourthViewableRIBRouting { + func build(withListener listener: FourthViewableRIBListener) -> (routing: FourthViewableRIBRouting, actionableItem: FourthViewableRIBActionableItem) { let component = FourthViewableRIBComponent(dependency: dependency) let viewController = FourthViewableRIBViewController() let presenter = FourthViewableRIBPresenter(viewController: viewController) let interactor = FourthViewableRIBInteractor(presenter: presenter) interactor.listener = listener - return FourthViewableRIBRouter(interactor: interactor, viewController: viewController) + let router = FourthViewableRIBRouter(interactor: interactor, viewController: viewController) + return (routing: router, actionableItem: interactor) } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift index 28e4154..4bb760b 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBRouter.swift @@ -7,7 +7,7 @@ import RIBs -protocol FourthViewableRIBInteractable: Interactable { +protocol FourthViewableRIBInteractable: Interactable, FourthViewableRIBActionableItem { var router: FourthViewableRIBRouting? { get set } var listener: FourthViewableRIBListener? { get set } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist b/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist index dd3c9af..e0c227b 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist @@ -2,6 +2,17 @@ + CFBundleURLTypes + + + CFBundleURLSchemes + + ribsappexample2 + + CFBundleURLName + RIBsAppExample2 Deep Link + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift index 29e7baf..fb1b7ed 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift @@ -12,7 +12,7 @@ protocol RootDependency: Dependency { // created by this RIB. } -final class RootComponent: Component, FirstViewableRIBDependency { +final class RootComponent: Component, FirstViewableRIBDependency { var firstViewableRIBBuilder: FirstViewableRIBBuildable { FirstViewableRIBBuilder(dependency: self) @@ -21,8 +21,13 @@ final class RootComponent: Component, FirstViewableRIBDependency // MARK: - Builder +struct RootBuildResult { + let launchRouter: LaunchRouting + let urlHandler: UrlHandler +} + protocol RootBuildable: Buildable { - func build() -> LaunchRouting + func build() -> RootBuildResult } final class RootBuilder: Builder, RootBuildable { @@ -31,10 +36,11 @@ final class RootBuilder: Builder, RootBuildable { super.init(dependency: dependency) } - func build() -> LaunchRouting { + func build() -> RootBuildResult { let component = RootComponent(dependency: dependency) let viewController = RootViewController() let interactor = RootInteractor(presenter: viewController) - return RootRouter(interactor: interactor, viewController: viewController, firstViewableRIBBuilder: component.firstViewableRIBBuilder) + let launchRouter = RootRouter(interactor: interactor, viewController: viewController, firstViewableRIBBuilder: component.firstViewableRIBBuilder) + return RootBuildResult(launchRouter: launchRouter, urlHandler: interactor) } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift index 91ff2b0..609e579 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift @@ -7,9 +7,10 @@ import RIBs import RxSwift +import Foundation protocol RootRouting: ViewableRouting { - func routeToFirstViewableRIB() + func routeToFirstViewableRIB() -> FirstViewableRIBActionableItem func routeAwayFromFirstViewableRIB() } @@ -22,11 +23,13 @@ protocol RootListener: AnyObject { // TODO: Declare methods the interactor can invoke to communicate with other RIBs. } -final class RootInteractor: PresentableInteractor, RootInteractable, RootPresentableListener { +final class RootInteractor: PresentableInteractor, RootInteractable, RootPresentableListener, UrlHandler { weak var router: RootRouting? weak var listener: RootListener? + private let firstViewableRIBActionableItemSubject = ReplaySubject.create(bufferSize: 1) + // TODO: Add additional dependencies to constructor. Do not perform any logic // in constructor. override init(presenter: RootPresentable) { @@ -36,12 +39,32 @@ final class RootInteractor: PresentableInteractor, RootInteract override func didBecomeActive() { super.didBecomeActive() - - router?.routeToFirstViewableRIB() + + if let firstItem = router?.routeToFirstViewableRIB() { + firstViewableRIBActionableItemSubject.onNext(firstItem) + } } override func willResignActive() { super.willResignActive() // TODO: Pause any business logic. } + + // MARK: - RootActionableItem + + func waitForFirstViewableRIB() -> Observable<(FirstViewableRIBActionableItem, ())> { + return firstViewableRIBActionableItemSubject.map { ($0, ()) } + } + + // MARK: - UrlHandler + + func handle(_ url: URL) { + switch url.path { + case "/example-deeplink": + let workflow = OpenFourthViewableRIBWorkflow() + workflow.subscribe(self).disposeOnDeactivate(interactor: self) + default: + break + } + } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift index 02a7bb4..b581397 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift @@ -7,7 +7,7 @@ import RIBs -protocol RootInteractable: Interactable, FirstViewableRIBListener { +protocol RootInteractable: Interactable, FirstViewableRIBListener, RootActionableItem { var router: RootRouting? { get set } var listener: RootListener? { get set } } @@ -18,7 +18,7 @@ protocol RootViewControllable: ViewControllable { } final class RootRouter: LaunchRouter, RootRouting { - + private let firstViewableRIBBuilder: FirstViewableRIBBuildable private var firstViewableRIBRouter: FirstViewableRIBRouting? @@ -27,15 +27,16 @@ final class RootRouter: LaunchRouter, Ro super.init(interactor: interactor, viewController: viewController) interactor.router = self } - - func routeToFirstViewableRIB() { - let firstViewableRIBRouter = firstViewableRIBBuilder.build(withListener: interactor) + + func routeToFirstViewableRIB() -> FirstViewableRIBActionableItem { + let (firstViewableRIBRouter, actionableItem) = firstViewableRIBBuilder.build(withListener: interactor) self.firstViewableRIBRouter = firstViewableRIBRouter let firstViewableRIBViewController = firstViewableRIBRouter.firstViewableRIBViewController viewController.embedMainView(firstViewableRIBViewController) attachChild(firstViewableRIBRouter) + return actionableItem } - + func routeAwayFromFirstViewableRIB() { if let firstViewableRIBRouter = firstViewableRIBRouter { self.firstViewableRIBRouter = nil diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift index 5e7dbff..75d5620 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift @@ -11,21 +11,27 @@ import RIBs class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - - private var launchRouter: LaunchRouting? + private var launchRouter: LaunchRouting? + private var urlHandler: UrlHandler? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } - - + let appComponent = AppComponent() - + let window = UIWindow(windowScene: windowScene) self.window = window - - let launchRouter = RootBuilder(dependency: appComponent).build() - self.launchRouter = launchRouter - launchRouter.launch(from: window) + + let result = RootBuilder(dependency: appComponent).build() + launchRouter = result.launchRouter + urlHandler = result.urlHandler + result.launchRouter.launch(from: window) + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + if let url = URLContexts.first?.url { + urlHandler?.handle(url) + } } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift index eed824a..6ca3acb 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift @@ -41,7 +41,7 @@ final class ThirdHeadlessRIBRouter: Router, ThirdH func routeToFourthRIB() { - let fourthViewableRIBRouter = fourthViewableRIBBuilder.build(withListener: interactor) + let (fourthViewableRIBRouter, fourthViewableRIBInteractor) = fourthViewableRIBBuilder.build(withListener: interactor) self.fourthViewableRIBRouter = fourthViewableRIBRouter let fourthViewableRIBViewControllable = fourthViewableRIBRouter.viewControllable attachChild(fourthViewableRIBRouter) diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Workflows/OpenFourthViewableRIBWorkflow.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Workflows/OpenFourthViewableRIBWorkflow.swift new file mode 100644 index 0000000..4cf2096 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Workflows/OpenFourthViewableRIBWorkflow.swift @@ -0,0 +1,47 @@ +// +// OpenFourthViewableRIBWorkflow.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/11/26. +// + +import RIBs +import RxSwift + +// MARK: - ActionableItem Protocols + +/// The root interactor's actionable interface used by workflows. +protocol RootActionableItem: AnyObject { + /// Emits when the First RIB is active and ready to accept workflow steps. + func waitForFirstViewableRIB() -> Observable<(FirstViewableRIBActionableItem, ())> +} + +/// The First RIB interactor's actionable interface used by workflows. +protocol FirstViewableRIBActionableItem: AnyObject { + /// Routes directly to the Fourth RIB, bypassing the Second/Third path. + func openFourthViewableRIB() -> Observable<(FourthViewableRIBActionableItem, ())> +} + +/// The Fourth RIB interactor's actionable interface used by workflows. +protocol FourthViewableRIBActionableItem: AnyObject {} + +// MARK: - Workflow + +/// A workflow triggered by the `ribsappexample2:///example-deeplink` deep link. +/// +/// Steps: +/// 1. Wait for the First RIB to become active (it starts automatically at launch). +/// 2. Route directly from First to Fourth, demonstrating cross-path navigation. +final class OpenFourthViewableRIBWorkflow: Workflow { + override init() { + super.init() + self + .onStep { (rootItem: RootActionableItem) -> Observable<(FirstViewableRIBActionableItem, ())> in + rootItem.waitForFirstViewableRIB() + } + .onStep { (firstItem: FirstViewableRIBActionableItem, _) -> Observable<(FourthViewableRIBActionableItem, ())> in + firstItem.openFourthViewableRIB() + } + .commit() + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/Workflows/UrlHandler.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Workflows/UrlHandler.swift new file mode 100644 index 0000000..6395207 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Workflows/UrlHandler.swift @@ -0,0 +1,12 @@ +// +// UrlHandler.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/11/26. +// + +import Foundation + +protocol UrlHandler: AnyObject { + func handle(_ url: URL) +} From 4ea89b9b43a5e8ca0b511b21828cc225adb628c9 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Wed, 11 Mar 2026 17:36:15 -0500 Subject: [PATCH 28/34] Restore project settings to defaults for brand new projects in Xcode 26 --- .../RIBsAppExample2.xcodeproj/project.pbxproj | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index b62adf5..bff5499 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7E71E73D2F4426FA002FD889 /* RIBs */; }; - 7EA3A6CE2F61EE4A00D01810 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EA3A6CD2F61EE4A00D01810 /* RIBs */; }; + 7EA3A6D62F62251D00D01810 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EA3A6D52F62251D00D01810 /* RIBs */; }; 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EB78D4E2F12CC0000547345 /* RIBs */; }; 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EFF41972F17D99D00B48704 /* RIBs */; }; /* End PBXBuildFile section */ @@ -59,7 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7EA3A6CE2F61EE4A00D01810 /* RIBs in Frameworks */, + 7EA3A6D62F62251D00D01810 /* RIBs in Frameworks */, 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */, 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */, 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */, @@ -117,7 +117,7 @@ 7EB78D4E2F12CC0000547345 /* RIBs */, 7EFF41972F17D99D00B48704 /* RIBs */, 7E71E73D2F4426FA002FD889 /* RIBs */, - 7EA3A6CD2F61EE4A00D01810 /* RIBs */, + 7EA3A6D52F62251D00D01810 /* RIBs */, ); productName = RIBsAppExample2; productReference = 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */; @@ -175,7 +175,7 @@ mainGroup = 7EB78D102F12CB3800547345; minimizedProjectReferenceProxies = 1; packageReferences = ( - 7EA3A6CC2F61EE4A00D01810 /* XCRemoteSwiftPackageReference "RIBs-iOS" */, + 7EA3A6D42F62251D00D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7EB78D1A2F12CB3800547345 /* Products */; @@ -254,10 +254,10 @@ OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -285,10 +285,10 @@ OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = io.mobileengineer.RIBsAppExample2; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -484,25 +484,20 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 7EA3A6CC2F61EE4A00D01810 /* XCRemoteSwiftPackageReference "RIBs-iOS" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/uber/RIBs-iOS.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; +/* Begin XCLocalSwiftPackageReference section */ + 7EA3A6D42F62251D00D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../RIBs-iOS"; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 7E71E73D2F4426FA002FD889 /* RIBs */ = { isa = XCSwiftPackageProductDependency; productName = RIBs; }; - 7EA3A6CD2F61EE4A00D01810 /* RIBs */ = { + 7EA3A6D52F62251D00D01810 /* RIBs */ = { isa = XCSwiftPackageProductDependency; - package = 7EA3A6CC2F61EE4A00D01810 /* XCRemoteSwiftPackageReference "RIBs-iOS" */; productName = RIBs; }; 7EB78D4E2F12CC0000547345 /* RIBs */ = { From 74dab0692874dac9269c1610ab525b028e703ea1 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Thu, 12 Mar 2026 15:07:58 -0500 Subject: [PATCH 29/34] Add ComponentizedBuilder example --- .../FirstViewableRIB/AuthService.swift | 18 +++++ .../FirstViewableRIBBuilder.swift | 16 ++++- .../FirstViewableRIBInteractor.swift | 33 ++++++++- .../FirstViewableRIBRouter.swift | 30 +++++++- .../FirstViewableRIBViewController.swift | 30 ++++++-- .../HomeRIB/HomeRIBBuilder.swift | 46 +++++++++++++ .../HomeRIB/HomeRIBInteractor.swift | 48 +++++++++++++ .../HomeRIB/HomeRIBRouter.swift | 23 +++++++ .../HomeRIB/HomeRIBViewController.swift | 68 +++++++++++++++++++ .../MainRIB/CurrentUserService.swift | 19 ++++++ .../MainRIB/MainRIBBuilder.swift | 49 +++++++++++++ .../MainRIB/MainRIBInteractor.swift | 53 +++++++++++++++ .../MainRIB/MainRIBRouter.swift | 44 ++++++++++++ .../MainRIB/MainRIBViewController.swift | 22 ++++++ .../RIBsAppExample2/UserSession.swift | 12 ++++ 15 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/AuthService.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBRouter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBViewController.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/CurrentUserService.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBBuilder.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBInteractor.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBRouter.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBViewController.swift create mode 100644 Examples/RIBsAppExample2/RIBsAppExample2/UserSession.swift diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/AuthService.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/AuthService.swift new file mode 100644 index 0000000..ed253b7 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/AuthService.swift @@ -0,0 +1,18 @@ +// +// AuthService.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +protocol AuthServiceType { + func login() async throws -> UserSession +} + +final class FakeAuthService: AuthServiceType { + + func login() async throws -> UserSession { + try await Task.sleep(nanoseconds: 2_000_000_000) + return UserSession(userId: "u_42", username: "alexvbush", authToken: "tok_abc123") + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift index b265a28..8c23065 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift @@ -12,7 +12,7 @@ protocol FirstViewableRIBDependency: Dependency { // created by this RIB. } -final class FirstViewableRIBComponent: Component, SecondViewableRIBDependency, FourthViewableRIBDependency { +final class FirstViewableRIBComponent: Component, SecondViewableRIBDependency, FourthViewableRIBDependency, MainRIBDependency { var actorService: ActorServicable { ActorService() @@ -29,6 +29,16 @@ final class FirstViewableRIBComponent: Component, Se var fourthViewableRIBBuilder: FourthViewableRIBBuildable { FourthViewableRIBBuilder(dependency: self) } + + var authService: AuthServiceType { + shared { FakeAuthService() } + } + + var mainRIBBuilder: MainRIBBuildable { + MainRIBBuilder { (userSession: UserSession) -> MainRIBComponent in + MainRIBComponent(dependency: self, userSession: userSession) + } + } } // MARK: - Builder @@ -46,9 +56,9 @@ final class FirstViewableRIBBuilder: Builder, FirstV func build(withListener listener: FirstViewableRIBListener) -> (routing: FirstViewableRIBRouting, actionableItem: FirstViewableRIBActionableItem) { let component = FirstViewableRIBComponent(dependency: dependency) let viewController = FirstViewableRIBViewController() - let interactor = FirstViewableRIBInteractor(presenter: viewController, actorService: component.actorService, rxSwiftService: component.rxSwiftService) + let interactor = FirstViewableRIBInteractor(presenter: viewController, actorService: component.actorService, rxSwiftService: component.rxSwiftService, authService: component.authService) interactor.listener = listener - let router = FirstViewableRIBRouter(interactor: interactor, viewController: viewController, secondViewableRIBBuilder: component.secondViewableRIBBuilder, fourthViewableRIBBuilder: component.fourthViewableRIBBuilder) + let router = FirstViewableRIBRouter(interactor: interactor, viewController: viewController, secondViewableRIBBuilder: component.secondViewableRIBBuilder, fourthViewableRIBBuilder: component.fourthViewableRIBBuilder, mainRIBBuilder: component.mainRIBBuilder) return (routing: router, actionableItem: interactor) } } diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift index efa1bb4..7a2d37b 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift @@ -16,6 +16,8 @@ protocol FirstViewableRIBRouting: ViewableRouting { func routeAwayFromSecondViewableRIB() func routeToFourthViewableRIB() -> FourthViewableRIBActionableItem func routeAwayFromFourthViewableRIB() + func routeToMainRIB(userSession: UserSession) + func routeAwayFromMainRIB() } protocol FirstViewableRIBPresentable: Presentable { @@ -34,10 +36,12 @@ final class FirstViewableRIBInteractor: PresentableInteractor.create { [authService] single in + Task { + do { + let session = try await authService.login() + single(.success(session)) + } catch { + single(.failure(error)) + } + } + return Disposables.create() + } + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { session in + self.router?.routeToMainRIB(userSession: session) + }) + .disposeOnDeactivate(interactor: self) + } + + // MARK: - MainRIBListener + + func didCompleteWithLogout(_ interactor: any MainRIBInteractable) { + router?.routeAwayFromMainRIB() + } + // MARK: - FirstViewableRIBActionableItem func openFourthViewableRIB() -> Observable<(FourthViewableRIBActionableItem, ())> { diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift index d9b0fa1..cf23295 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift @@ -7,7 +7,7 @@ import RIBs -protocol FirstViewableRIBInteractable: Interactable, FirstViewableRIBActionableItem, SecondViewableRIBListener, FourthViewableRIBListener { +protocol FirstViewableRIBInteractable: Interactable, FirstViewableRIBActionableItem, SecondViewableRIBListener, FourthViewableRIBListener, MainRIBListener { var router: FirstViewableRIBRouting? { get set } var listener: FirstViewableRIBListener? { get set } } @@ -24,9 +24,13 @@ final class FirstViewableRIBRouter: ViewableRouter { + + var currentUserService: CurrentUserServiceType { + dependency.currentUserService + } +} + +// MARK: - Buildable + +protocol HomeRIBBuildable: Buildable { + func build(withListener listener: HomeRIBListener) -> HomeRIBRouting +} + +// MARK: - Builder + +final class HomeRIBBuilder: Builder, HomeRIBBuildable { + + override init(dependency: HomeRIBDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: HomeRIBListener) -> HomeRIBRouting { + let component = HomeRIBComponent(dependency: dependency) + let viewController = HomeRIBViewController() + let interactor = HomeRIBInteractor(presenter: viewController, currentUserService: component.currentUserService) + interactor.listener = listener + return HomeRIBRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBInteractor.swift new file mode 100644 index 0000000..1e82074 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBInteractor.swift @@ -0,0 +1,48 @@ +// +// HomeRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs + +protocol HomeRIBRouting: ViewableRouting {} + +protocol HomeRIBPresentable: Presentable { + var listener: HomeRIBPresentableListener? { get set } + func presentUsername(_ username: String) +} + +protocol HomeRIBListener: AnyObject { + func didCompleteHomeByRequestingLogout(_ interactor: HomeRIBInteractable) +} + +final class HomeRIBInteractor: PresentableInteractor, HomeRIBInteractable, HomeRIBPresentableListener { + + weak var router: HomeRIBRouting? + weak var listener: HomeRIBListener? + + private let currentUserService: CurrentUserServiceType + + init(presenter: HomeRIBPresentable, currentUserService: CurrentUserServiceType) { + self.currentUserService = currentUserService + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + presenter.presentUsername(currentUserService.session.username) + } + + override func willResignActive() { + super.willResignActive() + } + + // MARK: - HomeRIBPresentableListener + + func logout() { + listener?.didCompleteHomeByRequestingLogout(self) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBRouter.swift new file mode 100644 index 0000000..6c3b7ec --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBRouter.swift @@ -0,0 +1,23 @@ +// +// HomeRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs + +protocol HomeRIBInteractable: Interactable { + var router: HomeRIBRouting? { get set } + var listener: HomeRIBListener? { get set } +} + +protocol HomeRIBViewControllable: ViewControllable {} + +final class HomeRIBRouter: ViewableRouter, HomeRIBRouting { + + override init(interactor: HomeRIBInteractable, viewController: HomeRIBViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBViewController.swift new file mode 100644 index 0000000..26e85be --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBViewController.swift @@ -0,0 +1,68 @@ +// +// HomeRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs +import UIKit + +protocol HomeRIBPresentableListener: AnyObject { + func logout() +} + +final class HomeRIBViewController: UIViewController, HomeRIBPresentable, HomeRIBViewControllable { + + weak var listener: HomeRIBPresentableListener? + + private let usernameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 20, weight: .medium) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let logoutButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Logout", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemTeal + title = "Home" + setupViews() + } + + // MARK: - HomeRIBPresentable + + func presentUsername(_ username: String) { + usernameLabel.text = "Welcome, \(username)!" + } + + // MARK: - Private + + private func setupViews() { + view.addSubview(usernameLabel) + view.addSubview(logoutButton) + + NSLayoutConstraint.activate([ + usernameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + usernameLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30), + + logoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoutButton.topAnchor.constraint(equalTo: usernameLabel.bottomAnchor, constant: 20), + ]) + + logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside) + } + + @objc private func logoutTapped() { + listener?.logout() + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/CurrentUserService.swift b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/CurrentUserService.swift new file mode 100644 index 0000000..303d65c --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/CurrentUserService.swift @@ -0,0 +1,19 @@ +// +// CurrentUserService.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +protocol CurrentUserServiceType: AnyObject { + var session: UserSession { get } +} + +final class CurrentUserService: CurrentUserServiceType { + + let session: UserSession + + init(session: UserSession) { + self.session = session + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBBuilder.swift new file mode 100644 index 0000000..8ffb157 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBBuilder.swift @@ -0,0 +1,49 @@ +// +// MainRIBBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs + +// MARK: - Dependency + +protocol MainRIBDependency: Dependency { + +} + +// MARK: - Component + +final class MainRIBComponent: Component, HomeRIBDependency { + + let currentUserService: CurrentUserServiceType + + init(dependency: MainRIBDependency, userSession: UserSession) { + self.currentUserService = CurrentUserService(session: userSession) + super.init(dependency: dependency) + } + + fileprivate var homeRIBBuilder: HomeRIBBuildable { + HomeRIBBuilder(dependency: self) + } +} + +// MARK: - Buildable + +protocol MainRIBBuildable: Buildable { + func build(withDynamicBuildDependency listener: MainRIBListener, + dynamicComponentDependency userSession: UserSession) -> MainRIBRouting +} + +// MARK: - Builder + +final class MainRIBBuilder: ComponentizedBuilder, MainRIBBuildable { + + override func build(with component: MainRIBComponent, _ listener: MainRIBListener) -> MainRIBRouting { + let viewController = MainRIBViewController() + let interactor = MainRIBInteractor(presenter: viewController, currentUserService: component.currentUserService) + interactor.listener = listener + return MainRIBRouter(interactor: interactor, viewController: viewController, homeRIBBuilder: component.homeRIBBuilder) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBInteractor.swift new file mode 100644 index 0000000..40f5d4d --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBInteractor.swift @@ -0,0 +1,53 @@ +// +// MainRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs + +protocol MainRIBRouting: ViewableRouting { + func routeToHomeRIB() + func routeAwayFromHomeRIB() +} + +protocol MainRIBPresentable: Presentable { + var listener: MainRIBPresentableListener? { get set } +} + +protocol MainRIBListener: AnyObject { + func didCompleteWithLogout(_ interactor: MainRIBInteractable) +} + +final class MainRIBInteractor: PresentableInteractor, MainRIBInteractable, MainRIBPresentableListener { + + weak var router: MainRIBRouting? + weak var listener: MainRIBListener? + + // MainRIBInteractor receives currentUserService directly, but it also lives + // in the component so HomeRIB can access it without going through Main. + private let currentUserService: CurrentUserServiceType + + init(presenter: MainRIBPresentable, currentUserService: CurrentUserServiceType) { + self.currentUserService = currentUserService + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + self.router?.routeToHomeRIB() + } + + override func willResignActive() { + super.willResignActive() + } + + // MARK: - HomeRIBListener + + func didCompleteHomeByRequestingLogout(_ interactor: any HomeRIBInteractable) { + listener?.didCompleteWithLogout(self) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBRouter.swift new file mode 100644 index 0000000..ddaf041 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBRouter.swift @@ -0,0 +1,44 @@ +// +// MainRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs + +protocol MainRIBInteractable: Interactable, HomeRIBListener { + var router: MainRIBRouting? { get set } + var listener: MainRIBListener? { get set } +} + +protocol MainRIBViewControllable: ViewControllable {} + +final class MainRIBRouter: ViewableRouter, MainRIBRouting { + + private let homeRIBBuilder: HomeRIBBuildable + private var homeRIBRouter: HomeRIBRouting? + + init(interactor: MainRIBInteractable, viewController: MainRIBViewControllable, homeRIBBuilder: HomeRIBBuildable) { + self.homeRIBBuilder = homeRIBBuilder + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } + + func routeToHomeRIB() { + let homeRIBRouter = homeRIBBuilder.build(withListener: interactor) + self.homeRIBRouter = homeRIBRouter + viewController.uiviewController.navigationController?.pushViewController( + homeRIBRouter.viewControllable.uiviewController, animated: false + ) + attachChild(homeRIBRouter) + } + + func routeAwayFromHomeRIB() { + if let homeRIBRouter = homeRIBRouter { + self.homeRIBRouter = nil + viewController.uiviewController.navigationController?.popToViewController(viewController.uiviewController, animated: true) + detachChild(homeRIBRouter) + } + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBViewController.swift new file mode 100644 index 0000000..d97e879 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/MainRIB/MainRIBViewController.swift @@ -0,0 +1,22 @@ +// +// MainRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs +import UIKit + +protocol MainRIBPresentableListener: AnyObject {} + +final class MainRIBViewController: UIViewController, MainRIBPresentable, MainRIBViewControllable { + + weak var listener: MainRIBPresentableListener? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemIndigo + title = "Main" + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/UserSession.swift b/Examples/RIBsAppExample2/RIBsAppExample2/UserSession.swift new file mode 100644 index 0000000..56c8932 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/UserSession.swift @@ -0,0 +1,12 @@ +// +// UserSession.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +struct UserSession { + let userId: String + let username: String + let authToken: String +} From e8cb22e829d9a51c8e3fd3ea79a2f0372704c38b Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sat, 21 Mar 2026 16:31:55 -0500 Subject: [PATCH 30/34] Add Swift 6 migration doc --- .../RIBsAppExample2.xcodeproj/project.pbxproj | 11 +- README.md | 2 + SWIFT6_STRICT_CONCURRENCY_MIGRATION.md | 138 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 SWIFT6_STRICT_CONCURRENCY_MIGRATION.md diff --git a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj index bff5499..d684d1a 100644 --- a/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 7E71E73E2F4426FA002FD889 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7E71E73D2F4426FA002FD889 /* RIBs */; }; 7EA3A6D62F62251D00D01810 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EA3A6D52F62251D00D01810 /* RIBs */; }; + 7EA3A6F42F635A7100D01810 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EA3A6F32F635A7100D01810 /* RIBs */; }; 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EB78D4E2F12CC0000547345 /* RIBs */; }; 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */ = {isa = PBXBuildFile; productRef = 7EFF41972F17D99D00B48704 /* RIBs */; }; /* End PBXBuildFile section */ @@ -59,6 +60,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7EA3A6F42F635A7100D01810 /* RIBs in Frameworks */, 7EA3A6D62F62251D00D01810 /* RIBs in Frameworks */, 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */, 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */, @@ -118,6 +120,7 @@ 7EFF41972F17D99D00B48704 /* RIBs */, 7E71E73D2F4426FA002FD889 /* RIBs */, 7EA3A6D52F62251D00D01810 /* RIBs */, + 7EA3A6F32F635A7100D01810 /* RIBs */, ); productName = RIBsAppExample2; productReference = 7EB78D192F12CB3800547345 /* RIBsAppExample2.app */; @@ -175,7 +178,7 @@ mainGroup = 7EB78D102F12CB3800547345; minimizedProjectReferenceProxies = 1; packageReferences = ( - 7EA3A6D42F62251D00D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, + 7EA3A6F22F635A7100D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7EB78D1A2F12CB3800547345 /* Products */; @@ -485,7 +488,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 7EA3A6D42F62251D00D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { + 7EA3A6F22F635A7100D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../../../RIBs-iOS"; }; @@ -500,6 +503,10 @@ isa = XCSwiftPackageProductDependency; productName = RIBs; }; + 7EA3A6F32F635A7100D01810 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + productName = RIBs; + }; 7EB78D4E2F12CC0000547345 /* RIBs */ = { isa = XCSwiftPackageProductDependency; productName = RIBs; diff --git a/README.md b/README.md index 2372f11..bf600d2 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ To get more hands on with RIBs, we have written a [series of tutorials](https:// To read about the backstory on why we created RIBs, see [this blog post](https://www.uber.com/blog/new-rider-app-architecture/) we wrote when releasing RIBs in production the first time and see [this short video](https://www.youtube.com/watch?v=Q5cTT0M0YXg) where we discussed how the RIBs architecture works. +If you are adopting Swift 6 strict concurrency in a project that uses RIBs, see the [Swift 6 Strict Concurrency Migration Guide](SWIFT6_STRICT_CONCURRENCY_MIGRATION.md). + #### What is the difference between RIBs and MV*/VIPER? MVC, MVP, MVI, MVVM and VIPER are architecture patterns. RIBs is a framework. What differentiates RIBs from frameworks based on MV*/VIPER is: diff --git a/SWIFT6_STRICT_CONCURRENCY_MIGRATION.md b/SWIFT6_STRICT_CONCURRENCY_MIGRATION.md new file mode 100644 index 0000000..59e983d --- /dev/null +++ b/SWIFT6_STRICT_CONCURRENCY_MIGRATION.md @@ -0,0 +1,138 @@ +# Migrating to Swift 6 Strict Concurrency with RIBs + +This guide covers what you need to do (if anything) when adopting Swift 6 and/or stricter concurrency settings in a project that uses RIBs. + +## Context + +The RIBs framework has always operated on the main thread at runtime. This release makes that explicit at the type system level by annotating all core framework types with `@MainActor`. For most existing projects this is a transparent, non-breaking change. For projects moving to Swift 6, the path depends on your default isolation setting. + +## Requirements + +`isolated deinit` — used in `Interactor`, `PresentableInteractor`, `Router`, `ViewableRouter`, and `Worker` — requires **Xcode 26.2 / Swift 6.2**. The rest of the `@MainActor` annotations are compatible with earlier toolchains. Apple requires apps submitted to the App Store to be built with Xcode 26 starting April 28, 2026 ([Apple's developer news](https://developer.apple.com/news/?id=ueeok6yw), more details [here](https://developer.apple.com/app-store/submitting/)). + +## Compatibility at a glance + +| Swift | Default isolation | Strictness | What you need to do | +|---|---|---|---| +| 5 | nonisolated | Minimal | Nothing | +| 5 | nonisolated | Targeted | Nothing | +| 5 | nonisolated | Complete | Nothing | +| 5 | `@MainActor` | Minimal | Nothing | +| 5 | `@MainActor` | Targeted | Nothing | +| 5 | `@MainActor` | Complete | Nothing | +| 6 | nonisolated | Minimal | ❌ Not supported — see below | +| 6 | nonisolated | Targeted | ❌ Not supported — see below | +| 6 | nonisolated | Complete | ❌ Not supported — see below | +| 6 | `@MainActor` | Minimal | Switch to `@MainActor` default + handle RxSwift caveat | +| 6 | `@MainActor` | Targeted | Switch to `@MainActor` default + handle RxSwift caveat | +| 6 | `@MainActor` | Complete | Switch to `@MainActor` default + handle RxSwift caveat | + +--- + +## Swift 5 (all configurations) + +No action required. `@MainActor` annotations on library types are additive and fully source-compatible. Your existing RIB subclasses compile and behave identically. + +--- + +## Swift 6 + `nonisolated` default (not supported) + +When your project uses Swift 6 with `nonisolated` as the default isolation, your own types are implicitly `nonisolated`. Subclassing or conforming to `@MainActor`-annotated framework types produces actor isolation mismatch compile errors throughout your RIB code. This configuration is not supported. + +**Your options:** + +1. **Stay on Swift 5** — fully supported in all configurations, no code changes needed. +2. **Switch to Swift 6 with `@MainActor` default isolation** — the supported path for Swift 6 users (see next section). +3. **Stay on Swift 6 with `nonisolated` default and add explicit `@MainActor` annotations throughout your RIB code** — if switching your entire project's default isolation is not feasible, you can keep `nonisolated` as the default and manually annotate your code to satisfy the compiler. This goes beyond just annotating RIB subclasses — you will also need to annotate the protocols your app defines (presentable listener protocols, interactor listener protocols, routing protocols, etc.) and potentially other types that interact with RIBs at isolation boundaries. The exact scope of changes depends on your codebase. This path is possible but is left to you to work through; the compiler will guide you to every site that needs attention. + +--- + +## Swift 6 + `@MainActor` default isolation + +This is the target configuration. With `@MainActor` as your project's default isolation, your own types are also implicitly `@MainActor`, aligning with the framework. This is how brand new projects with Xcode 26+ are set up by default. + +### Enabling it + +In your Xcode project's build settings: + +- **Swift Language Version:** Swift 6 +- **Swift Compiler — Upcoming Features / Strict Concurrency:** your choice of Minimal, Targeted, or Complete — all work +- **Default Actor Isolation:** `@MainActor` + (`-default-isolation MainActor` in `OTHER_SWIFT_FLAGS` if setting manually) + +### Your RIB subclasses + +Your custom `Interactor`, `Router`, `Builder`, `Worker`, and `Presenter` subclasses inherit `@MainActor` isolation through the base classes. No annotation needed in most cases. + +### Services and injected dependencies + +Anything passed into a RIB via constructor injection through a `Component` must be compatible with `@MainActor`: + +- Types that are themselves `@MainActor` — no changes needed +- Types that are `Sendable` — no changes needed +- Types that do background work — mark them `nonisolated` where appropriate, or use `async`/`await` to cross actor boundaries explicitly + +### `deinit` in your own RIB subclasses + +If you have custom `deinit` implementations that access `@MainActor`-isolated state, mark them `isolated deinit` (Xcode 26.2 / Swift 6.2 required): + +```swift +final class MyInteractor: PresentableInteractor { + isolated deinit { + // safe to access @MainActor state here + someMainActorResource.cleanup() + } +} +``` + +If you are on an earlier toolchain temporarily, `nonisolated(unsafe)` is a stopgap, but migrate to `isolated deinit` as soon as your toolchain supports it. + +--- + +## RxSwift `@Sendable` caveat (Swift 6 only) + +With Swift 6 enabled, closures passed to RxSwift operators (`map`, `filter`, `flatMap`, `subscribe`, etc.) must be `@Sendable` or you will encounter a runtime crash. This is a known RxSwift limitation ([ReactiveX/RxSwift#2639](https://github.com/ReactiveX/RxSwift/pull/2639)) that predates and is independent of these RIBs changes. + +**Option A — annotate affected closures:** + +```swift +observable + .map { @Sendable value in transform(value) } + .subscribe(onNext: { @Sendable value in handle(value) }) +``` + +**Option B — migrate to async/await:** + +RIBs now fully supports async/await at the type system level. The standard bridging pattern for one-shot async work is: + +```swift +Single.create { single in + Task { + do { + let result = try await myAsyncFunction() + single(.success(result)) + } catch { + single(.failure(error)) + } + } + return Disposables.create() +} +.observe(on: MainScheduler.instance) +.subscribe(onSuccess: { [weak self] result in + self?.handle(result) +}) +.disposeOnDeactivate(interactor: self) +``` + +Additional async/await convenience utilities are planned as a follow-up release, making this pattern even more concise. + +--- + +## Summary + +| Scenario | Action | +|---|---| +| Staying on Swift 5 | Nothing — fully compatible | +| Moving to Swift 6, keeping `nonisolated` default | Not supported without changes; annotate each RIB subclass explicitly with `@MainActor`, or switch to `@MainActor` default | +| Moving to Swift 6, switching to `@MainActor` default | Enable `@MainActor` default isolation; handle RxSwift `@Sendable` if using RxSwift | +| Custom `deinit` accessing main-actor state | Use `isolated deinit` (Xcode 26.2+) | From 84a882b18d3a543f01e33cebc5edb1bc85a2ae91 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Thu, 2 Apr 2026 23:33:42 -0500 Subject: [PATCH 31/34] Add XcodeBuildMCP server config --- .mcp.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..2530a57 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "XcodeBuildMCP": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest", + "mcp" + ], + "env": {} + } + } +} \ No newline at end of file From 6e0fe4af897db841d4ac9d23923dedc2be5e90fe Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sun, 5 Apr 2026 16:16:32 -0500 Subject: [PATCH 32/34] Add RxSwift @Sendable Swift 6 migration tool --- tooling/RxSendableMigrator/Package.resolved | 41 +++ tooling/RxSendableMigrator/Package.swift | 27 ++ .../Sources/RxSendableMigrator/CLI.swift | 165 ++++++++++ .../IndexStoreDiscovery.swift | 96 ++++++ .../IndexStoreService.swift | 98 ++++++ .../RxSendableRewriter.swift | 165 ++++++++++ .../RxSendableMigrator/VendorFilter.swift | 22 ++ .../IndexStoreDiscoveryTests.swift | 191 ++++++++++++ .../VendorFilterTests.swift | 138 +++++++++ .../add_sendable_to_rxswift_closures.swift | 126 ++++++++ ...d_sendable_to_rxswift_closures_tests.swift | 284 ++++++++++++++++++ 11 files changed, 1353 insertions(+) create mode 100644 tooling/RxSendableMigrator/Package.resolved create mode 100644 tooling/RxSendableMigrator/Package.swift create mode 100644 tooling/RxSendableMigrator/Sources/RxSendableMigrator/CLI.swift create mode 100644 tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift create mode 100644 tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift create mode 100644 tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift create mode 100644 tooling/RxSendableMigrator/Sources/RxSendableMigrator/VendorFilter.swift create mode 100644 tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IndexStoreDiscoveryTests.swift create mode 100644 tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/VendorFilterTests.swift create mode 100755 tooling/add_sendable_to_rxswift_closures.swift create mode 100755 tooling/add_sendable_to_rxswift_closures_tests.swift diff --git a/tooling/RxSendableMigrator/Package.resolved b/tooling/RxSendableMigrator/Package.resolved new file mode 100644 index 0000000..0be9b7b --- /dev/null +++ b/tooling/RxSendableMigrator/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "indexstore-db", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/indexstore-db", + "state" : { + "branch" : "main", + "revision" : "b1354e1201314cb8cbbc00c9deb92a3ecd16a710" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-lmdb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-lmdb.git", + "state" : { + "branch" : "main", + "revision" : "a4bc87807721c1fd114bf35464457e2db0d0e6c0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + } + ], + "version" : 2 +} diff --git a/tooling/RxSendableMigrator/Package.swift b/tooling/RxSendableMigrator/Package.swift new file mode 100644 index 0000000..cfab259 --- /dev/null +++ b/tooling/RxSendableMigrator/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "RxSendableMigrator", + platforms: [.macOS(.v14)], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax", from: "600.0.0"), + .package(url: "https://github.com/apple/indexstore-db", branch: "main"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + ], + targets: [ + .executableTarget( + name: "RxSendableMigrator", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "IndexStoreDB", package: "indexstore-db"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .testTarget( + name: "RxSendableMigratorTests", + dependencies: ["RxSendableMigrator"] + ), + ] +) diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/CLI.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/CLI.swift new file mode 100644 index 0000000..0fbea7a --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/CLI.swift @@ -0,0 +1,165 @@ +import Foundation +import ArgumentParser +import SwiftSyntax +import SwiftParser + +@main +struct RxSendableMigrator: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "rx-sendable-migrator", + abstract: "Injects @Sendable into RxSwift operator closures for Swift 6 migration." + ) + + @Option(name: .long, help: "Path to the Swift source directory to migrate.") + var sourceDir: String + + @Option( + name: .long, + help: "Path to the Xcode index store (e.g. DerivedData/.../Index/DataStore). Auto-detected from DerivedData if omitted." + ) + var indexStorePath: String? + + @Flag(name: .long, help: "Print changes without writing to disk.") + var dryRun = false + + @Option(name: .long, help: "Print IndexStore lookup details for files matching this suffix (e.g. LoginInteractor.swift).") + var debugFile: String? + + func run() throws { + let resolvedIndexStorePath = try resolveIndexStorePath() + let indexService = try IndexStoreService(storePath: resolvedIndexStorePath) + + let sourceDirURL = URL(fileURLWithPath: sourceDir, isDirectory: true) + guard let enumerator = FileManager.default.enumerator( + at: sourceDirURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + print("Could not enumerate \(sourceDir)") + return + } + + var filesModified = 0 + + for case let fileURL as URL in enumerator { + // Skip vendor/build directories + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDir) + if isDir.boolValue { + if VendorFilter.shouldExclude(directoryName: fileURL.lastPathComponent) { + enumerator.skipDescendants() + } + continue + } + + guard fileURL.pathExtension == "swift" else { continue } + + let source: String + do { + source = try String(contentsOf: fileURL, encoding: .utf8) + } catch { + fputs("Warning: could not read \(fileURL.path): \(error)\n", stderr) + continue + } + + let modified = migrateFile(source: source, filePath: fileURL.path, indexService: indexService) + + guard modified != source else { continue } + + filesModified += 1 + if dryRun { + printUnifiedDiff(original: source, modified: modified, path: fileURL.path) + } else { + do { + try modified.write(to: fileURL, atomically: true, encoding: .utf8) + print("Modified: \(fileURL.path)") + } catch { + fputs("Error writing \(fileURL.path): \(error)\n", stderr) + } + } + } + + let verb = dryRun ? "would be" : "were" + print("\nDone. \(filesModified) file(s) \(verb) modified.") + } + + // MARK: - Private + + private func resolveIndexStorePath() throws -> String { + if let explicit = indexStorePath { + return explicit + } + guard let discovered = IndexStoreDiscovery.findIndexStorePath(for: sourceDir) else { + throw ValidationError( + "Could not auto-detect an index store for \(sourceDir). " + + "Build the project in Xcode first, or pass --index-store-path explicitly." + ) + } + print("Auto-detected index store: \(discovered)") + return discovered + } + + private func printUnifiedDiff(original: String, modified: String, path: String) { + let originalLines = original.components(separatedBy: "\n") + let modifiedLines = modified.components(separatedBy: "\n") + + // Collect changed hunks (±3 lines of context) + let context = 3 + var hunks: [(range: Range, lines: [String])] = [] + var i = 0 + while i < max(originalLines.count, modifiedLines.count) { + let origLine = i < originalLines.count ? originalLines[i] : nil + let modLine = i < modifiedLines.count ? modifiedLines[i] : nil + if origLine != modLine { + let start = max(0, i - context) + var end = min(max(originalLines.count, modifiedLines.count), i + 1) + // extend end until lines match again for context + while end < max(originalLines.count, modifiedLines.count) { + let oe = end < originalLines.count ? originalLines[end] : nil + let me = end < modifiedLines.count ? modifiedLines[end] : nil + if oe == me { break } + end += 1 + } + end = min(max(originalLines.count, modifiedLines.count), end + context) + + var hunkLines: [String] = [] + for j in start.. String { + let tree = Parser.parse(source: source) + let converter = SourceLocationConverter(fileName: filePath, tree: tree) + let rewriter = RxSendableRewriter( + filePath: filePath, + locationConverter: converter, + indexService: indexService, + debugFile: debugFile + ) + let newTree = rewriter.visit(tree) + return newTree.description + } +} diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift new file mode 100644 index 0000000..e894b73 --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift @@ -0,0 +1,96 @@ +import Foundation + +struct IndexStoreDiscovery { + + /// Searches `~/Library/Developer/Xcode/DerivedData` for the most appropriate + /// IndexStore DataStore directory for the given source directory. + /// + /// Strategy: + /// 1. Sort all DerivedData entries by modification date (newest first). + /// 2. Prefer entries whose `info.plist` WorkspacePath is related to `sourceDir` + /// (i.e. the workspace lives inside or contains `sourceDir`). + /// 3. Fall back to the most-recently-modified entry that has a valid DataStore. + static func findIndexStorePath(for sourceDir: String) -> String? { + let derivedDataURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Developer/Xcode/DerivedData") + + guard let entries = try? FileManager.default.contentsOfDirectory( + at: derivedDataURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { return nil } + + let sorted = entries.sorted { modDate(of: $0) > modDate(of: $1) } + let sourceDirURL = URL(fileURLWithPath: sourceDir).resolvingSymlinksInPath() + + for entry in sorted { + if let workspacePath = readWorkspacePath(from: entry), + isRelated(workspacePath: workspacePath, to: sourceDirURL), + let dataStore = dataStorePath(in: entry) { + return dataStore + } + } + + // Fallback: newest DataStore regardless of workspace match + for entry in sorted { + if let dataStore = dataStorePath(in: entry) { + return dataStore + } + } + + return nil + } + + // MARK: - Internal helpers (exposed for testing) + + /// Reads the `WorkspacePath` key from a DerivedData entry's `info.plist`. + static func readWorkspacePath(from derivedDataEntry: URL) -> String? { + let infoPlist = derivedDataEntry.appendingPathComponent("info.plist") + guard + let data = try? Data(contentsOf: infoPlist), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let workspacePath = plist["WorkspacePath"] as? String + else { return nil } + return workspacePath + } + + /// Returns `true` when `workspacePath`'s parent directory is an ancestor or descendant + /// of `sourceDir` (after resolving symlinks on both sides). + static func isRelated(workspacePath: String, to sourceDir: URL) -> Bool { + let workspaceDir = URL(fileURLWithPath: workspacePath) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + let resolvedSource = sourceDir.resolvingSymlinksInPath() + + let a = workspaceDir.path + let b = resolvedSource.path + + return b.hasPrefix(a + "/") || b == a + || a.hasPrefix(b + "/") || a == b + } + + /// Returns the DataStore path inside a DerivedData entry, or `nil` if none exists. + /// Supports both modern (`Index.noindex/DataStore`) and legacy (`Index/DataStore`) layouts. + static func dataStorePath(in derivedDataEntry: URL) -> String? { + let modern = derivedDataEntry.appendingPathComponent("Index.noindex/DataStore") + if FileManager.default.fileExists( + atPath: modern.appendingPathComponent("v5/units").path + ) { + return modern.path + } + + let legacy = derivedDataEntry.appendingPathComponent("Index/DataStore") + if FileManager.default.fileExists(atPath: legacy.path) { + return legacy.path + } + + return nil + } + + // MARK: - Private + + private static func modDate(of url: URL) -> Date { + (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? .distantPast + } +} diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift new file mode 100644 index 0000000..02a43da --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift @@ -0,0 +1,98 @@ +import Foundation +import IndexStoreDB + +enum MigratorError: Error, CustomStringConvertible { + case indexStoreLibraryNotFound + + var description: String { + "Could not find libIndexStore.dylib. Ensure Xcode is installed and xcrun is available." + } +} + +final class IndexStoreService { + private let db: IndexStoreDB + + init(storePath: String) throws { + let libPath = try Self.findIndexStoreLibrary() + let library = try IndexStoreLibrary(dylibPath: libPath) + + // IndexStoreDB needs a scratch directory for its own derived cache. + // The directory must exist before passing it to IndexStoreDB (LMDB requires it). + let databaseURL = FileManager.default.temporaryDirectory + .appendingPathComponent("RxSendableMigrator-db-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: databaseURL, withIntermediateDirectories: true) + let databasePath = databaseURL.path + + self.db = try IndexStoreDB( + storePath: storePath, + databasePath: databasePath, + library: library, + waitUntilDoneInitializing: true, + readonly: false + ) + } + + /// Returns true if the call site at (file, line, column) resolves to a RxSwift symbol. + /// `line` and `column` are 1-based, UTF-8 byte offsets — matching SourceLocationConverter output. + func isRxSwiftCall(file: String, line: Int, column: Int, debugFile: String? = nil) -> Bool { + // symbolOccurrences(inFilePath:) returns all symbol references recorded in the file. + // We filter to the exact call-site position, then check the symbol's USR. + // Swift USRs are mangled names; RxSwift symbols contain "RxSwift" in their USR + // (e.g. "s:7RxSwift17ObservableConvertibleTypeP3map..."). + let occs = db.symbolOccurrences(inFilePath: file) + if let debugFile, file.hasSuffix(debugFile) { + let nearby = occs.filter { abs($0.location.line - line) <= 1 } + .sorted { $0.location.line < $1.location.line } + for o in nearby { + let hit = o.location.line == line && o.location.utf8Column == column + fputs(" [\(hit ? "HIT" : " ")] line=\(o.location.line) col=\(o.location.utf8Column) name=\(o.symbol.name) usr=\(o.symbol.usr.prefix(60))\n", stderr) + } + } + return occs.contains { occ in + occ.location.line == line && + occ.location.utf8Column == column && + occ.symbol.usr.contains("RxSwift") + } + } + + // MARK: - Private + + private static func findIndexStoreLibrary() throws -> String { + // Ask xcrun where clang lives; derive the toolchain lib path from that + if let candidate = toolchainLibPath() { + return candidate + } + // Fallback to default Xcode install location + let fallback = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/libIndexStore.dylib" + guard FileManager.default.fileExists(atPath: fallback) else { + throw MigratorError.indexStoreLibraryNotFound + } + return fallback + } + + private static func toolchainLibPath() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--find", "clang"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + guard (try? process.run()) != nil else { return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !output.isEmpty else { return nil } + + // clang lives at .../usr/bin/clang → lib is at .../usr/lib/libIndexStore.dylib + let libPath = URL(fileURLWithPath: output) + .deletingLastPathComponent() // remove "clang" + .deletingLastPathComponent() // remove "bin" + .appendingPathComponent("lib/libIndexStore.dylib") + .path + + return FileManager.default.fileExists(atPath: libPath) ? libPath : nil + } +} diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift new file mode 100644 index 0000000..5f8ab55 --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftSyntax + +final class RxSendableRewriter: SyntaxRewriter { + let filePath: String + let locationConverter: SourceLocationConverter + let indexService: IndexStoreService + + // Operators whose closure parameters need @Sendable. + // These are operators that *store* the closure and call it from RxSwift's internal + // scheduler machinery — potentially on a different thread from where it was created. + // + // Excluded intentionally: + // subscribe / subscribeNext / subscribeError / subscribeCompleted / bind / drive — + // terminal consumers; by the time you subscribe you've typically already switched + // to the right scheduler with observe(on:), so the closure executes where expected. + // observeOn / subscribeOn / debounce / throttle / timeout / delay — + // take schedulers or time intervals, not user closures. + // merge / concat / switchLatest / amb / startWith / share / publish / replay / + // multicast / toArray / materialize / dematerialize / ignoreElements — + // no user-supplied transform/predicate closures. + static let rxOperators: Set = [ + // Transforming — closure is applied on the source scheduler + "map", "compactMap", + "flatMap", "flatMapLatest", "flatMapFirst", "flatMapWithIndex", "concatMap", + "scan", "reduce", + "groupBy", + "buffer", "window", + + // Filtering / conditional — predicate is called on the source scheduler + "filter", + "distinctUntilChanged", + "skipWhile", "takeWhile", + "single", + + // Combining — result-selector closure is called on the source scheduler + "withLatestFrom", + "combineLatest", + "zip", + + // Error handling — handler is called wherever the error was thrown + "catch", "catchError", "catchErrorJustReturn", + "retryWhen", + + // Side effects — closures fire on the source scheduler, same risk as map/filter + "do", + ] + + var debugFile: String? + + init(filePath: String, locationConverter: SourceLocationConverter, indexService: IndexStoreService, debugFile: String? = nil) { + self.filePath = filePath + self.locationConverter = locationConverter + self.indexService = indexService + self.debugFile = debugFile + super.init() + } + + override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax { + // Capture the operator's source position from the ORIGINAL node before recursing. + // super.visit() modifies children (e.g. injects @Sendable into inner closures), + // which shifts byte offsets in the returned tree. If we read the position after + // recursing, the shifted offset maps to the wrong line/column in SourceLocationConverter + // (which was built from the original source). + let originalLocation = captureLocation(of: node) + + // Recurse into children so inner operators are handled first. + let visited = super.visit(node) + guard let callNode = visited.as(FunctionCallExprSyntax.self) else { + return visited + } + return ExprSyntax(processCall(callNode, originalLocation: originalLocation) ?? callNode) + } + + // MARK: - Core transformation + + private struct OperatorLocation { + let name: String + let line: Int + let column: Int + } + + /// Reads the operator name and its source position from the node as it exists + /// in the original (unmodified) source tree. + private func captureLocation(of node: FunctionCallExprSyntax) -> OperatorLocation? { + guard let memberAccess = node.calledExpression.as(MemberAccessExprSyntax.self) else { + return nil + } + let name = memberAccess.declName.baseName.text + guard Self.rxOperators.contains(name) else { return nil } + let loc = locationConverter.location( + for: memberAccess.declName.baseName.positionAfterSkippingLeadingTrivia + ) + return OperatorLocation(name: name, line: loc.line, column: loc.column) + } + + private func processCall(_ node: FunctionCallExprSyntax, originalLocation: OperatorLocation?) -> FunctionCallExprSyntax? { + guard let loc = originalLocation else { return nil } + + if let debugFile, filePath.hasSuffix(debugFile) { + fputs("Checking \(loc.name) at line=\(loc.line) col=\(loc.column)\n", stderr) + } + guard indexService.isRxSwiftCall(file: filePath, line: loc.line, column: loc.column, debugFile: debugFile) else { + return nil + } + + // Prefer trailing closure, then fall back to a closure nested in arguments. + if let trailingClosure = node.trailingClosure, + let newClosure = injectSendable(into: trailingClosure) { + return node.with(\.trailingClosure, newClosure) + } + + var elements = Array(node.arguments) + for i in elements.indices { + if let closure = elements[i].expression.as(ClosureExprSyntax.self), + let newClosure = injectSendable(into: closure) { + elements[i] = elements[i].with(\.expression, ExprSyntax(newClosure)) + return node.with(\.arguments, LabeledExprListSyntax(elements)) + } + } + + return nil + } + + // MARK: - Closure mutation + + private func injectSendable(into closure: ClosureExprSyntax) -> ClosureExprSyntax? { + if let sig = closure.signature { + guard !hasSendable(sig.attributes) else { return nil } + let newSig = sig.with(\.attributes, prependSendable(to: sig.attributes)) + return closure.with(\.signature, newSig) + } else { + // Bare closure like `{ $0 + 1 }` — synthesize `{ @Sendable in $0 + 1 }` + let newSig = ClosureSignatureSyntax( + attributes: AttributeListSyntax([.attribute(makeSendableAttribute(trailingSpace: true))]), + inKeyword: .keyword(.in, leadingTrivia: [], trailingTrivia: .space) + ) + return closure.with(\.signature, newSig) + } + } + + private func prependSendable(to attrs: AttributeListSyntax) -> AttributeListSyntax { + // @Sendable needs a trailing space to separate it from whatever follows. + let sendable = AttributeListSyntax.Element.attribute(makeSendableAttribute(trailingSpace: true)) + return AttributeListSyntax([sendable] + Array(attrs)) + } + + private func hasSendable(_ attrs: AttributeListSyntax) -> Bool { + attrs.contains { element in + guard case .attribute(let attr) = element, + let ident = attr.attributeName.as(IdentifierTypeSyntax.self) + else { return false } + return ident.name.text == "Sendable" + } + } + + private func makeSendableAttribute(trailingSpace: Bool) -> AttributeSyntax { + AttributeSyntax( + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax( + name: .identifier("Sendable", trailingTrivia: trailingSpace ? .spaces(1) : []) + ) + ) + } +} diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/VendorFilter.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/VendorFilter.swift new file mode 100644 index 0000000..811426c --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/VendorFilter.swift @@ -0,0 +1,22 @@ +import Foundation + +struct VendorFilter { + /// Directory names that are unconditionally skipped during source file enumeration. + /// These are vendor dependency managers and build artefacts we never want to migrate. + static let excludedDirectoryNames: Set = [ + "Pods", // CocoaPods + "Carthage", // Carthage + ".build", // Swift Package Manager build output + ".swiftpm", // SPM package cache + "vendor", // Generic vendor directory + "Packages", // Resolved Swift packages + ".git", // Git internals + "build", // Generic Xcode/CMake build output + "DerivedData", // Xcode derived data (if nested in source tree) + "node_modules", // JavaScript tooling (React Native, etc.) + ] + + static func shouldExclude(directoryName: String) -> Bool { + excludedDirectoryNames.contains(directoryName) + } +} diff --git a/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IndexStoreDiscoveryTests.swift b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IndexStoreDiscoveryTests.swift new file mode 100644 index 0000000..504d8e7 --- /dev/null +++ b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IndexStoreDiscoveryTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import RxSendableMigrator + +final class IndexStoreDiscoveryTests: XCTestCase { + + // MARK: - isRelated + + func test_isRelated_sourceDirInsideWorkspaceDir() { + let workspacePath = "/projects/MyApp/iosApp/MyApp.xcworkspace" + let sourceDir = URL(fileURLWithPath: "/projects/MyApp/iosApp/Sources") + XCTAssertTrue(IndexStoreDiscovery.isRelated(workspacePath: workspacePath, to: sourceDir)) + } + + func test_isRelated_sourceDirEqualsWorkspaceDir() { + let workspacePath = "/projects/MyApp/iosApp/MyApp.xcworkspace" + let sourceDir = URL(fileURLWithPath: "/projects/MyApp/iosApp") + XCTAssertTrue(IndexStoreDiscovery.isRelated(workspacePath: workspacePath, to: sourceDir)) + } + + func test_isRelated_workspaceDirInsideSourceDir() { + // Source dir is repo root; workspace is nested inside + let workspacePath = "/repo/iosApp/iosApp.xcworkspace" + let sourceDir = URL(fileURLWithPath: "/repo") + XCTAssertTrue(IndexStoreDiscovery.isRelated(workspacePath: workspacePath, to: sourceDir)) + } + + func test_isRelated_unrelatedPaths() { + let workspacePath = "/projects/OtherApp/OtherApp.xcworkspace" + let sourceDir = URL(fileURLWithPath: "/projects/MyApp/iosApp") + XCTAssertFalse(IndexStoreDiscovery.isRelated(workspacePath: workspacePath, to: sourceDir)) + } + + func test_isRelated_partialPrefixDoesNotMatch() { + // "/projects/MyApp" must not match "/projects/MyAppExtension" + let workspacePath = "/projects/MyAppExtension/MyAppExtension.xcworkspace" + let sourceDir = URL(fileURLWithPath: "/projects/MyApp/iosApp") + XCTAssertFalse(IndexStoreDiscovery.isRelated(workspacePath: workspacePath, to: sourceDir)) + } + + // MARK: - readWorkspacePath + + func test_readWorkspacePath_returnsPath() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + try writePlist(["WorkspacePath": "/projects/MyApp/MyApp.xcworkspace"], to: dir) + + XCTAssertEqual( + IndexStoreDiscovery.readWorkspacePath(from: dir), + "/projects/MyApp/MyApp.xcworkspace" + ) + } + + func test_readWorkspacePath_missingPlist_returnsNil() { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + XCTAssertNil(IndexStoreDiscovery.readWorkspacePath(from: dir)) + } + + func test_readWorkspacePath_missingKey_returnsNil() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + try writePlist(["SomeOtherKey": "value"], to: dir) + + XCTAssertNil(IndexStoreDiscovery.readWorkspacePath(from: dir)) + } + + // MARK: - dataStorePath + + func test_dataStorePath_modernFormat() throws { + let entry = try makeTempDir() + defer { cleanup(entry) } + + try makeDir(entry.appendingPathComponent("Index.noindex/DataStore/v5/units")) + + XCTAssertEqual( + IndexStoreDiscovery.dataStorePath(in: entry), + entry.appendingPathComponent("Index.noindex/DataStore").path + ) + } + + func test_dataStorePath_legacyFormat() throws { + let entry = try makeTempDir() + defer { cleanup(entry) } + + try makeDir(entry.appendingPathComponent("Index/DataStore")) + + XCTAssertEqual( + IndexStoreDiscovery.dataStorePath(in: entry), + entry.appendingPathComponent("Index/DataStore").path + ) + } + + func test_dataStorePath_noIndex_returnsNil() throws { + let entry = try makeTempDir() + defer { cleanup(entry) } + + XCTAssertNil(IndexStoreDiscovery.dataStorePath(in: entry)) + } + + func test_dataStorePath_prefersModernOverLegacy() throws { + let entry = try makeTempDir() + defer { cleanup(entry) } + + try makeDir(entry.appendingPathComponent("Index.noindex/DataStore/v5/units")) + try makeDir(entry.appendingPathComponent("Index/DataStore")) + + XCTAssertEqual( + IndexStoreDiscovery.dataStorePath(in: entry), + entry.appendingPathComponent("Index.noindex/DataStore").path + ) + } + + // MARK: - findIndexStorePath (integration using fake DerivedData) + + func test_findIndexStorePath_picksRelatedEntry() throws { + // We can't override the system DerivedData path, so we test the building blocks + // that `findIndexStorePath` composes. This test validates the full selection + // logic by calling `readWorkspacePath`, `isRelated`, and `dataStorePath` together. + + let fakeDerived = try makeFakeDerivedDataEntry( + workspacePath: "/projects/MyApp/iosApp/MyApp.xcworkspace" + ) + defer { cleanup(fakeDerived.root) } + + let sourceDir = URL(fileURLWithPath: "/projects/MyApp/iosApp/Sources") + + let workspacePath = IndexStoreDiscovery.readWorkspacePath(from: fakeDerived.entry) + let related = workspacePath.map { IndexStoreDiscovery.isRelated(workspacePath: $0, to: sourceDir) } ?? false + let dataStore = IndexStoreDiscovery.dataStorePath(in: fakeDerived.entry) + + XCTAssertNotNil(workspacePath) + XCTAssertTrue(related) + XCTAssertEqual(dataStore, fakeDerived.dataStorePath) + } + + func test_findIndexStorePath_rejectsUnrelatedEntry() throws { + let fakeDerived = try makeFakeDerivedDataEntry( + workspacePath: "/projects/OtherApp/OtherApp.xcworkspace" + ) + defer { cleanup(fakeDerived.root) } + + let sourceDir = URL(fileURLWithPath: "/projects/MyApp/iosApp/Sources") + + let workspacePath = IndexStoreDiscovery.readWorkspacePath(from: fakeDerived.entry) + let related = workspacePath.map { IndexStoreDiscovery.isRelated(workspacePath: $0, to: sourceDir) } ?? false + + XCTAssertFalse(related) + } + + // MARK: - Helpers + + private struct FakeDerivedDataEntry { + let root: URL + let entry: URL + let dataStorePath: String + } + + private func makeFakeDerivedDataEntry(workspacePath: String) throws -> FakeDerivedDataEntry { + let root = try makeTempDir() + let entry = root.appendingPathComponent("MyApp-abcdef1234") + try makeDir(entry.appendingPathComponent("Index.noindex/DataStore/v5/units")) + try writePlist(["WorkspacePath": workspacePath], to: entry) + return FakeDerivedDataEntry( + root: root, + entry: entry, + dataStorePath: entry.appendingPathComponent("Index.noindex/DataStore").path + ) + } + + private func makeTempDir() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("IndexStoreDiscoveryTest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func makeDir(_ url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + private func writePlist(_ dict: [String: Any], to dir: URL) throws { + let data = try PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) + try data.write(to: dir.appendingPathComponent("info.plist")) + } + + private func cleanup(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/VendorFilterTests.swift b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/VendorFilterTests.swift new file mode 100644 index 0000000..dc3ead6 --- /dev/null +++ b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/VendorFilterTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import RxSendableMigrator + +final class VendorFilterTests: XCTestCase { + + // MARK: - shouldExclude + + func test_excludes_pods() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: "Pods")) + } + + func test_excludes_carthage() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: "Carthage")) + } + + func test_excludes_swiftBuild() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: ".build")) + } + + func test_excludes_swiftpm() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: ".swiftpm")) + } + + func test_excludes_vendor() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: "vendor")) + } + + func test_excludes_git() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: ".git")) + } + + func test_excludes_build() { + XCTAssertTrue(VendorFilter.shouldExclude(directoryName: "build")) + } + + func test_doesNotExclude_sourceDirs() { + XCTAssertFalse(VendorFilter.shouldExclude(directoryName: "Sources")) + XCTAssertFalse(VendorFilter.shouldExclude(directoryName: "Interactors")) + XCTAssertFalse(VendorFilter.shouldExclude(directoryName: "MyFeature")) + XCTAssertFalse(VendorFilter.shouldExclude(directoryName: "RxSwift")) + } + + // MARK: - Enumeration integration + + func test_enumerationSkipsPodsFiles() throws { + let root = try makeProjectTree([ + "Sources/MyInteractor.swift": "// source", + "Pods/RxSwift/Observable.swift": "// pod", + "Pods/RxSwift/Nested/Deep.swift": "// deep pod", + ]) + defer { try? FileManager.default.removeItem(at: root) } + + let found = collectSwiftFiles(under: root) + + XCTAssertEqual(found.map(\.lastPathComponent).sorted(), ["MyInteractor.swift"]) + } + + func test_enumerationSkipsCarthageFiles() throws { + let root = try makeProjectTree([ + "Sources/MyInteractor.swift": "// source", + "Carthage/Build/SomeLib.swift": "// carthage", + ]) + defer { try? FileManager.default.removeItem(at: root) } + + let found = collectSwiftFiles(under: root) + + XCTAssertEqual(found.map(\.lastPathComponent).sorted(), ["MyInteractor.swift"]) + } + + func test_enumerationSkipsMultipleVendorDirs() throws { + let root = try makeProjectTree([ + "Sources/A.swift": "// a", + "Sources/B.swift": "// b", + "Pods/PodFile.swift": "// pod", + "Carthage/CartFile.swift": "// cart", + ".build/BuildFile.swift": "// build", + ]) + defer { try? FileManager.default.removeItem(at: root) } + + let found = collectSwiftFiles(under: root) + + XCTAssertEqual(found.map(\.lastPathComponent).sorted(), ["A.swift", "B.swift"]) + } + + func test_enumerationKeepsAllSourceFilesWhenNoVendorDirs() throws { + let root = try makeProjectTree([ + "FeatureA/Interactor.swift": "// a", + "FeatureB/Router.swift": "// b", + "FeatureB/Builder.swift": "// c", + ]) + defer { try? FileManager.default.removeItem(at: root) } + + let found = collectSwiftFiles(under: root) + + XCTAssertEqual(found.count, 3) + } + + // MARK: - Helpers + + /// Mimics the enumeration loop in CLI.run(), applying VendorFilter. + private func collectSwiftFiles(under root: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + var result: [URL] = [] + for case let url as URL in enumerator { + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + if isDir.boolValue { + if VendorFilter.shouldExclude(directoryName: url.lastPathComponent) { + enumerator.skipDescendants() + } + continue + } + guard url.pathExtension == "swift" else { continue } + result.append(url) + } + return result + } + + /// Creates a temporary directory tree from a `[relativePath: content]` dictionary. + private func makeProjectTree(_ files: [String: String]) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("VendorFilterTest-\(UUID().uuidString)") + for (relativePath, content) in files { + let fileURL = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + } + return root + } +} diff --git a/tooling/add_sendable_to_rxswift_closures.swift b/tooling/add_sendable_to_rxswift_closures.swift new file mode 100755 index 0000000..78df62c --- /dev/null +++ b/tooling/add_sendable_to_rxswift_closures.swift @@ -0,0 +1,126 @@ +#!/usr/bin/env swift +// +// add_sendable_to_rxswift_closures.swift +// +// Heuristic script: adds @Sendable to explicit-parameter closures in Swift files +// that import RxSwift or RxRelay. +// +// Background +// ---------- +// Swift 6 strict concurrency requires closures passed to RxSwift operators (map, +// filter, subscribe, etc.) to be marked @Sendable. This script inserts @Sendable +// into the closure header automatically for the common forms: +// +// .map { value in ... } -> .map { @Sendable value in ... } +// .map { (value: T) in ... } -> .map { @Sendable (value: T) in ... } +// .map { [weak self] value in ... } -> .map { [weak self] @Sendable value in ... } +// .subscribe(onNext: { _ in ... }) -> .subscribe(onNext: { @Sendable _ in ... }) +// +// Limitations +// ----------- +// - Shorthand closures ($0, $1) have no explicit parameter list and cannot be +// annotated automatically. Rewrite them with explicit parameters first; the +// Swift compiler will identify every remaining site. +// - Multi-line closures where the parameter list is on a different line from the +// opening { are not matched and must be fixed manually. +// - @Sendable is inserted in ALL eligible closures in files that import RxSwift, +// not only those inside RxSwift operator calls. This is generally harmless — +// an unnecessary @Sendable is not an error — but review the diff carefully. +// +// Usage +// ----- +// swift tooling/add_sendable_to_rxswift_closures.swift # whole repo +// swift tooling/add_sendable_to_rxswift_closures.swift path/to/src # subtree +// +// After running, review every change with `git diff` before committing. + +import Foundation + +// Matches a closure opening brace followed by an optional capture list, on the +// condition that @Sendable is not already present and that the word `in` appears +// before the next brace or newline (distinguishing closures from plain block +// openers like `if condition {` or `class Foo {`). +// +// Group 1 captures: optional whitespace + optional [capture-list] + optional +// whitespace, so we can re-emit it verbatim and insert @Sendable afterwards. +let patternString = #"(? String { + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + guard !matches.isEmpty else { return text } + + // Process in reverse order to preserve string offsets as we mutate. + var result = text + for match in matches.reversed() { + guard + let fullRange = Range(match.range, in: result), + let captureRange = Range(match.range(at: 1), in: result) + else { continue } + let capture = String(result[captureRange]) + result.replaceSubrange(fullRange, with: "{\(capture)@Sendable ") + } + return result +} + +func processFile(at url: URL) -> Bool { + guard !url.path.contains("/Pods/") else { return false } + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return false } + guard text.contains("import RxSwift") || text.contains("import RxRelay") else { return false } + + let updated = transform(text) + guard updated != text else { return false } + + do { + try updated.write(to: url, atomically: true, encoding: .utf8) + return true + } catch { + fputs("error: could not write \(url.path): \(error)\n", stderr) + return false + } +} + +let rootPath = CommandLine.arguments.count > 1 + ? CommandLine.arguments[1] + : FileManager.default.currentDirectoryPath +let rootURL = URL(fileURLWithPath: rootPath) + +guard FileManager.default.fileExists(atPath: rootPath) else { + fputs("error: path does not exist: \(rootPath)\n", stderr) + exit(1) +} + +let enumerator = FileManager.default.enumerator( + at: rootURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] +) + +var changed: [URL] = [] +while let fileURL = enumerator?.nextObject() as? URL { + guard fileURL.pathExtension == "swift" else { continue } + if processFile(at: fileURL) { + changed.append(fileURL) + } +} + +if changed.isEmpty { + print("No files modified.") +} else { + print("Modified \(changed.count) file(s):") + for url in changed.sorted(by: { $0.path < $1.path }) { + print(" \(url.path)") + } + print() + print("Next steps:") + print(" 1. Review changes: git diff") + print(" 2. Build the project — the compiler will surface any remaining sites,") + print(" including shorthand closures ($0/$1) that need explicit parameters") + print(" before @Sendable can be applied.") + print(" 3. Commit when satisfied.") +} diff --git a/tooling/add_sendable_to_rxswift_closures_tests.swift b/tooling/add_sendable_to_rxswift_closures_tests.swift new file mode 100755 index 0000000..d761b1f --- /dev/null +++ b/tooling/add_sendable_to_rxswift_closures_tests.swift @@ -0,0 +1,284 @@ +#!/usr/bin/env swift +// +// add_sendable_to_rxswift_closures_tests.swift +// +// Tests for add_sendable_to_rxswift_closures.swift +// +// Usage: +// swift tooling/add_sendable_to_rxswift_closures_tests.swift + +import Foundation + +// MARK: - Transform logic (kept in sync with add_sendable_to_rxswift_closures.swift) + +let patternString = #"(? String { + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, range: range) + guard !matches.isEmpty else { return text } + var result = text + for match in matches.reversed() { + guard + let fullRange = Range(match.range, in: result), + let captureRange = Range(match.range(at: 1), in: result) + else { continue } + let capture = String(result[captureRange]) + result.replaceSubrange(fullRange, with: "{\(capture)@Sendable ") + } + return result +} + +func shouldProcessFile(path: String = "MyFile.swift", text: String) -> Bool { + guard !path.contains("/Pods/") else { return false } + return text.contains("import RxSwift") || text.contains("import RxRelay") +} + +// MARK: - Test runner + +var passed = 0 +var failed = 0 + +func test(_ name: String, input: String, expected: String) { + let result = transform(input) + if result == expected { + print("✓ \(name)") + passed += 1 + } else { + print("✗ \(name)") + print(" input: \(input.debugDescription)") + print(" expected: \(expected.debugDescription)") + print(" got: \(result.debugDescription)") + failed += 1 + } +} + +func testNoChange(_ name: String, input: String) { + test(name, input: input, expected: input) +} + +func testBool(_ name: String, value: Bool, expected: Bool) { + if value == expected { + print("✓ \(name)") + passed += 1 + } else { + print("✗ \(name)") + print(" expected: \(expected), got: \(value)") + failed += 1 + } +} + +// MARK: - Tests: should transform + +print("-- should transform --") + +// Real pattern from trainerbase-mobile/HomeInteractor.swift +test( + "plain parameter — subscribe trailing closure", + input: ".subscribe { response in", + expected: ".subscribe { @Sendable response in" +) +test( + "subscribe void — underscore", + input: ".subscribe { _ in", + expected: ".subscribe { @Sendable _ in" +) + + +// Other common forms +test( + "typed parameter", + input: ".map { (value: Int) in", + expected: ".map { @Sendable (value: Int) in" +) +test( + "multiple captures", + input: ".map { [weak self, unowned coordinator] value in", + expected: ".map { [weak self, unowned coordinator] @Sendable value in" +) + +// MARK: - Tests: should NOT transform + +print() +print("-- should NOT transform --") + +// onSuccess: and onFailure: labeled closures are excluded +testNoChange( + "onSuccess: labeled closure — plain parameter", + input: ".subscribe(onSuccess: { clients in" +) +testNoChange( + "onSuccess: labeled closure — long name", + input: ".subscribe(onSuccess: { currentSubscription in" +) +testNoChange( + "onSuccess: labeled closure — with chain", + input: ".observe(on: MainScheduler.instance).subscribe(onSuccess: { updatedEvent in" +) +testNoChange( + "onSuccess: labeled closure — [weak self]", + input: ".subscribe(onSuccess: { [weak self] customerInfo in" +) +testNoChange( + "onFailure: labeled closure — plain parameter", + input: "}, onFailure: { error in" +) +testNoChange( + "onFailure: trailing closure syntax", + input: "} onFailure: { error in" +) +testNoChange( + "onFailure: labeled closure — [weak self]", + input: "}, onFailure: { [weak self] error in" +) +testNoChange( + "fromAsync closure — capture list only", + input: "Single<[Client]>.fromAsync { [clientsService] in" +) +testNoChange( + "fromAsync closure — no capture list", + input: "Single.fromAsync { in" +) + +// Already annotated — idempotency +testNoChange( + "already has @Sendable — no capture list", + input: ".map { @Sendable value in" +) +testNoChange( + "already has @Sendable — with capture list", + input: ".map { [weak self] @Sendable value in" +) +testNoChange( + "already has @Sendable — capture list only", + input: "Single.fromAsync { [userService] @Sendable in" +) + +// Shorthand closures — no `in`, cannot be annotated +testNoChange( + "shorthand closure — $0", + input: "self.clients.first(where: { Int($0.id) == id })" +) +testNoChange( + "shorthand closure — expression body", + input: ".map { $0.name }" +) + +// Block openers that are not closures +testNoChange( + "class declaration", + input: "final class ClientsListInteractor: BaseInteractor {" +) +testNoChange( + "function declaration", + input: "private func fetchClients(withQuery query: String? = nil) {" +) +testNoChange( + "if statement", + input: "if clients.isEmpty {" +) +testNoChange( + "guard statement", + input: "guard let self = self else {" +) +testNoChange( + "for-in loop", + input: "for client in clients {" +) +testNoChange( + "function body brace followed by call on next line", + input: "func presentEmptyState() {\n internalView.renderEmptyStateMessage(\"Add payments here to track when clients are due.\")\n}" +) +testNoChange( + "function body brace — no content on same line as brace", + input: "func doSomething() {" +) + +// MARK: - Tests: script adds ONLY @Sendable — no capture list injection + +print() +print("-- adds @Sendable only, nothing else --") + +// These verify the script never injects [weak self] or any other capture. +test( + "no [weak self] added to plain closure", + input: ".map { customerInfo in", + expected: ".map { @Sendable customerInfo in" +) +testNoChange( + "no [weak self] added to fromAsync closure", + input: "Single.fromAsync { [userService] in" +) +test( + "existing [weak self] preserved exactly", + input: ".subscribe { [weak self] info in", + expected: ".subscribe { [weak self] @Sendable info in" +) + +// MARK: - Tests: file import guard + +print() +print("-- file import guard --") + +testBool( + "processes files with import RxSwift", + value: shouldProcessFile(text: "import UIKit\nimport RxSwift\nimport RIBs\n"), + expected: true +) +testBool( + "processes files with import RxRelay", + value: shouldProcessFile(text: "import RxRelay\n"), + expected: true +) +testBool( + "skips files with neither import", + value: shouldProcessFile(text: "import UIKit\nimport Foundation\n"), + expected: false +) +testBool( + "skips files with only import RIBs", + value: shouldProcessFile(text: "import RIBs\nimport UIKit\n"), + expected: false +) +testBool( + "skips Pods files even with import RxSwift", + value: shouldProcessFile(path: "/project/Pods/RxRelay/Observable+Bind.swift", text: "import RxSwift\n"), + expected: false +) +testBool( + "skips nested Pods path", + value: shouldProcessFile(path: "/project/Pods/RxSwift/RxSwift/Observable.swift", text: "import RxSwift\n"), + expected: false +) + +// MARK: - Tests: idempotency + +print() +print("-- idempotency --") + +let cases = [ + ".subscribe { response in", + ".subscribe { _ in", + ".map { value in", + ".filter { item in", +] +for input in cases { + let oncePassed = transform(input) + let twicePassed = transform(oncePassed) + test( + "idempotent: \(input.debugDescription)", + input: twicePassed, + expected: oncePassed + ) +} + +// MARK: - Summary + +print() +let total = passed + failed +print("\(total) test(s): \(passed) passed, \(failed) failed") +if failed > 0 { exit(1) } From bc4b76158e01dcf406bda834aecc017d4f3194ef Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Sun, 5 Apr 2026 16:50:01 -0500 Subject: [PATCH 33/34] Add more tests to the RxSwift @Sendable migration tool --- tooling/RxSendableMigrator/.swift-version | 1 + tooling/RxSendableMigrator/Package.swift | 2 +- .../IndexStoreService.swift | 6 +- .../RxSendableRewriter.swift | 7 +- .../RxSendableRewriterTests.swift | 84 ++++++ .../add_sendable_to_rxswift_closures.swift | 126 -------- ...d_sendable_to_rxswift_closures_tests.swift | 284 ------------------ 7 files changed, 96 insertions(+), 414 deletions(-) create mode 100644 tooling/RxSendableMigrator/.swift-version create mode 100644 tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/RxSendableRewriterTests.swift delete mode 100755 tooling/add_sendable_to_rxswift_closures.swift delete mode 100755 tooling/add_sendable_to_rxswift_closures_tests.swift diff --git a/tooling/RxSendableMigrator/.swift-version b/tooling/RxSendableMigrator/.swift-version new file mode 100644 index 0000000..42cc526 --- /dev/null +++ b/tooling/RxSendableMigrator/.swift-version @@ -0,0 +1 @@ +6.2.4 diff --git a/tooling/RxSendableMigrator/Package.swift b/tooling/RxSendableMigrator/Package.swift index cfab259..90e34cb 100644 --- a/tooling/RxSendableMigrator/Package.swift +++ b/tooling/RxSendableMigrator/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version:5.9 import PackageDescription let package = Package( diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift index 02a43da..18cbefb 100644 --- a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift @@ -9,7 +9,11 @@ enum MigratorError: Error, CustomStringConvertible { } } -final class IndexStoreService { +protocol IndexStoreProviding { + func isRxSwiftCall(file: String, line: Int, column: Int, debugFile: String?) -> Bool +} + +final class IndexStoreService: IndexStoreProviding { private let db: IndexStoreDB init(storePath: String) throws { diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift index 5f8ab55..69f4452 100644 --- a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift @@ -4,7 +4,7 @@ import SwiftSyntax final class RxSendableRewriter: SyntaxRewriter { let filePath: String let locationConverter: SourceLocationConverter - let indexService: IndexStoreService + let indexService: IndexStoreProviding // Operators whose closure parameters need @Sendable. // These are operators that *store* the closure and call it from RxSwift's internal @@ -48,7 +48,7 @@ final class RxSendableRewriter: SyntaxRewriter { var debugFile: String? - init(filePath: String, locationConverter: SourceLocationConverter, indexService: IndexStoreService, debugFile: String? = nil) { + init(filePath: String, locationConverter: SourceLocationConverter, indexService: IndexStoreProviding, debugFile: String? = nil) { self.filePath = filePath self.locationConverter = locationConverter self.indexService = indexService @@ -127,6 +127,9 @@ final class RxSendableRewriter: SyntaxRewriter { private func injectSendable(into closure: ClosureExprSyntax) -> ClosureExprSyntax? { if let sig = closure.signature { guard !hasSendable(sig.attributes) else { return nil } + + // If there's a capture list, @Sendable must go AFTER it. + // attributes in ClosureSignatureSyntax are technically after capture list but before parameters. let newSig = sig.with(\.attributes, prependSendable(to: sig.attributes)) return closure.with(\.signature, newSig) } else { diff --git a/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/RxSendableRewriterTests.swift b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/RxSendableRewriterTests.swift new file mode 100644 index 0000000..dc14d64 --- /dev/null +++ b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/RxSendableRewriterTests.swift @@ -0,0 +1,84 @@ +import XCTest +import SwiftSyntax +import SwiftParser +@testable import RxSendableMigrator + +final class RxSendableRewriterTests: XCTestCase { + + func test_rewrite_standardClosure_injectsSendable() { + assertRewrite( + input: ".map { value in value + 1 }", + expected: ".map { @Sendable value in value + 1 }" + ) + } + + func test_rewrite_shorthandClosure_synthesizesSignature() { + assertRewrite( + input: ".map { $0 + 1 }", + expected: ".map { @Sendable in $0 + 1 }" + ) + } + + func test_rewrite_closureWithCaptureList_injectsBeforeCapture() { + assertRewrite( + input: ".map { [weak self] value in self?.transform(value) }", + expected: ".map { @Sendable [weak self] value in self?.transform(value) }" + ) + } + + func test_rewrite_alreadyHasSendable_doesNotDuplicate() { + assertRewrite( + input: ".map { @Sendable value in value + 1 }", + expected: ".map { @Sendable value in value + 1 }" + ) + } + + func test_rewrite_noParams_synthesizesSignature() { + assertRewrite( + input: ".do { print(\"side effect\") }", + expected: ".do { @Sendable in print(\"side effect\") }" + ) + } + + func test_rewrite_subscribe_shouldNotBeConverted() { + // As per user instructions and comments in RxSendableRewriter.swift + assertRewrite( + input: ".subscribe { value in print(value) }", + expected: ".subscribe { value in print(value) }" + ) + } + + func test_rewrite_bind_shouldNotBeConverted() { + assertRewrite( + input: ".bind { value in print(value) }", + expected: ".bind { value in print(value) }" + ) + } + + func test_rewrite_nonRxOperator_shouldNotBeConverted() { + assertRewrite( + input: ".someOtherMethod { value in value + 1 }", + expected: ".someOtherMethod { value in value + 1 }" + ) + } + + // MARK: - Helpers + + private func assertRewrite(input: String, expected: String, file: StaticString = #file, line: UInt = #line) { + let tree = Parser.parse(source: input) + let rewriter = RxSendableRewriter( + filePath: "test.swift", + locationConverter: SourceLocationConverter(fileName: "test.swift", tree: tree), + indexService: MockIndexStoreProvider() + ) + let modified = rewriter.visit(tree) + XCTAssertEqual(modified.description, expected, file: file, line: line) + } +} + +private final class MockIndexStoreProvider: IndexStoreProviding { + func isRxSwiftCall(file: String, line: Int, column: Int, debugFile: String?) -> Bool { + // For unit tests of the rewriter, we assume whitelisted operators are Rx calls + return true + } +} diff --git a/tooling/add_sendable_to_rxswift_closures.swift b/tooling/add_sendable_to_rxswift_closures.swift deleted file mode 100755 index 78df62c..0000000 --- a/tooling/add_sendable_to_rxswift_closures.swift +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env swift -// -// add_sendable_to_rxswift_closures.swift -// -// Heuristic script: adds @Sendable to explicit-parameter closures in Swift files -// that import RxSwift or RxRelay. -// -// Background -// ---------- -// Swift 6 strict concurrency requires closures passed to RxSwift operators (map, -// filter, subscribe, etc.) to be marked @Sendable. This script inserts @Sendable -// into the closure header automatically for the common forms: -// -// .map { value in ... } -> .map { @Sendable value in ... } -// .map { (value: T) in ... } -> .map { @Sendable (value: T) in ... } -// .map { [weak self] value in ... } -> .map { [weak self] @Sendable value in ... } -// .subscribe(onNext: { _ in ... }) -> .subscribe(onNext: { @Sendable _ in ... }) -// -// Limitations -// ----------- -// - Shorthand closures ($0, $1) have no explicit parameter list and cannot be -// annotated automatically. Rewrite them with explicit parameters first; the -// Swift compiler will identify every remaining site. -// - Multi-line closures where the parameter list is on a different line from the -// opening { are not matched and must be fixed manually. -// - @Sendable is inserted in ALL eligible closures in files that import RxSwift, -// not only those inside RxSwift operator calls. This is generally harmless — -// an unnecessary @Sendable is not an error — but review the diff carefully. -// -// Usage -// ----- -// swift tooling/add_sendable_to_rxswift_closures.swift # whole repo -// swift tooling/add_sendable_to_rxswift_closures.swift path/to/src # subtree -// -// After running, review every change with `git diff` before committing. - -import Foundation - -// Matches a closure opening brace followed by an optional capture list, on the -// condition that @Sendable is not already present and that the word `in` appears -// before the next brace or newline (distinguishing closures from plain block -// openers like `if condition {` or `class Foo {`). -// -// Group 1 captures: optional whitespace + optional [capture-list] + optional -// whitespace, so we can re-emit it verbatim and insert @Sendable afterwards. -let patternString = #"(? String { - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - guard !matches.isEmpty else { return text } - - // Process in reverse order to preserve string offsets as we mutate. - var result = text - for match in matches.reversed() { - guard - let fullRange = Range(match.range, in: result), - let captureRange = Range(match.range(at: 1), in: result) - else { continue } - let capture = String(result[captureRange]) - result.replaceSubrange(fullRange, with: "{\(capture)@Sendable ") - } - return result -} - -func processFile(at url: URL) -> Bool { - guard !url.path.contains("/Pods/") else { return false } - guard let text = try? String(contentsOf: url, encoding: .utf8) else { return false } - guard text.contains("import RxSwift") || text.contains("import RxRelay") else { return false } - - let updated = transform(text) - guard updated != text else { return false } - - do { - try updated.write(to: url, atomically: true, encoding: .utf8) - return true - } catch { - fputs("error: could not write \(url.path): \(error)\n", stderr) - return false - } -} - -let rootPath = CommandLine.arguments.count > 1 - ? CommandLine.arguments[1] - : FileManager.default.currentDirectoryPath -let rootURL = URL(fileURLWithPath: rootPath) - -guard FileManager.default.fileExists(atPath: rootPath) else { - fputs("error: path does not exist: \(rootPath)\n", stderr) - exit(1) -} - -let enumerator = FileManager.default.enumerator( - at: rootURL, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] -) - -var changed: [URL] = [] -while let fileURL = enumerator?.nextObject() as? URL { - guard fileURL.pathExtension == "swift" else { continue } - if processFile(at: fileURL) { - changed.append(fileURL) - } -} - -if changed.isEmpty { - print("No files modified.") -} else { - print("Modified \(changed.count) file(s):") - for url in changed.sorted(by: { $0.path < $1.path }) { - print(" \(url.path)") - } - print() - print("Next steps:") - print(" 1. Review changes: git diff") - print(" 2. Build the project — the compiler will surface any remaining sites,") - print(" including shorthand closures ($0/$1) that need explicit parameters") - print(" before @Sendable can be applied.") - print(" 3. Commit when satisfied.") -} diff --git a/tooling/add_sendable_to_rxswift_closures_tests.swift b/tooling/add_sendable_to_rxswift_closures_tests.swift deleted file mode 100755 index d761b1f..0000000 --- a/tooling/add_sendable_to_rxswift_closures_tests.swift +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env swift -// -// add_sendable_to_rxswift_closures_tests.swift -// -// Tests for add_sendable_to_rxswift_closures.swift -// -// Usage: -// swift tooling/add_sendable_to_rxswift_closures_tests.swift - -import Foundation - -// MARK: - Transform logic (kept in sync with add_sendable_to_rxswift_closures.swift) - -let patternString = #"(? String { - let range = NSRange(text.startIndex..., in: text) - let matches = regex.matches(in: text, range: range) - guard !matches.isEmpty else { return text } - var result = text - for match in matches.reversed() { - guard - let fullRange = Range(match.range, in: result), - let captureRange = Range(match.range(at: 1), in: result) - else { continue } - let capture = String(result[captureRange]) - result.replaceSubrange(fullRange, with: "{\(capture)@Sendable ") - } - return result -} - -func shouldProcessFile(path: String = "MyFile.swift", text: String) -> Bool { - guard !path.contains("/Pods/") else { return false } - return text.contains("import RxSwift") || text.contains("import RxRelay") -} - -// MARK: - Test runner - -var passed = 0 -var failed = 0 - -func test(_ name: String, input: String, expected: String) { - let result = transform(input) - if result == expected { - print("✓ \(name)") - passed += 1 - } else { - print("✗ \(name)") - print(" input: \(input.debugDescription)") - print(" expected: \(expected.debugDescription)") - print(" got: \(result.debugDescription)") - failed += 1 - } -} - -func testNoChange(_ name: String, input: String) { - test(name, input: input, expected: input) -} - -func testBool(_ name: String, value: Bool, expected: Bool) { - if value == expected { - print("✓ \(name)") - passed += 1 - } else { - print("✗ \(name)") - print(" expected: \(expected), got: \(value)") - failed += 1 - } -} - -// MARK: - Tests: should transform - -print("-- should transform --") - -// Real pattern from trainerbase-mobile/HomeInteractor.swift -test( - "plain parameter — subscribe trailing closure", - input: ".subscribe { response in", - expected: ".subscribe { @Sendable response in" -) -test( - "subscribe void — underscore", - input: ".subscribe { _ in", - expected: ".subscribe { @Sendable _ in" -) - - -// Other common forms -test( - "typed parameter", - input: ".map { (value: Int) in", - expected: ".map { @Sendable (value: Int) in" -) -test( - "multiple captures", - input: ".map { [weak self, unowned coordinator] value in", - expected: ".map { [weak self, unowned coordinator] @Sendable value in" -) - -// MARK: - Tests: should NOT transform - -print() -print("-- should NOT transform --") - -// onSuccess: and onFailure: labeled closures are excluded -testNoChange( - "onSuccess: labeled closure — plain parameter", - input: ".subscribe(onSuccess: { clients in" -) -testNoChange( - "onSuccess: labeled closure — long name", - input: ".subscribe(onSuccess: { currentSubscription in" -) -testNoChange( - "onSuccess: labeled closure — with chain", - input: ".observe(on: MainScheduler.instance).subscribe(onSuccess: { updatedEvent in" -) -testNoChange( - "onSuccess: labeled closure — [weak self]", - input: ".subscribe(onSuccess: { [weak self] customerInfo in" -) -testNoChange( - "onFailure: labeled closure — plain parameter", - input: "}, onFailure: { error in" -) -testNoChange( - "onFailure: trailing closure syntax", - input: "} onFailure: { error in" -) -testNoChange( - "onFailure: labeled closure — [weak self]", - input: "}, onFailure: { [weak self] error in" -) -testNoChange( - "fromAsync closure — capture list only", - input: "Single<[Client]>.fromAsync { [clientsService] in" -) -testNoChange( - "fromAsync closure — no capture list", - input: "Single.fromAsync { in" -) - -// Already annotated — idempotency -testNoChange( - "already has @Sendable — no capture list", - input: ".map { @Sendable value in" -) -testNoChange( - "already has @Sendable — with capture list", - input: ".map { [weak self] @Sendable value in" -) -testNoChange( - "already has @Sendable — capture list only", - input: "Single.fromAsync { [userService] @Sendable in" -) - -// Shorthand closures — no `in`, cannot be annotated -testNoChange( - "shorthand closure — $0", - input: "self.clients.first(where: { Int($0.id) == id })" -) -testNoChange( - "shorthand closure — expression body", - input: ".map { $0.name }" -) - -// Block openers that are not closures -testNoChange( - "class declaration", - input: "final class ClientsListInteractor: BaseInteractor {" -) -testNoChange( - "function declaration", - input: "private func fetchClients(withQuery query: String? = nil) {" -) -testNoChange( - "if statement", - input: "if clients.isEmpty {" -) -testNoChange( - "guard statement", - input: "guard let self = self else {" -) -testNoChange( - "for-in loop", - input: "for client in clients {" -) -testNoChange( - "function body brace followed by call on next line", - input: "func presentEmptyState() {\n internalView.renderEmptyStateMessage(\"Add payments here to track when clients are due.\")\n}" -) -testNoChange( - "function body brace — no content on same line as brace", - input: "func doSomething() {" -) - -// MARK: - Tests: script adds ONLY @Sendable — no capture list injection - -print() -print("-- adds @Sendable only, nothing else --") - -// These verify the script never injects [weak self] or any other capture. -test( - "no [weak self] added to plain closure", - input: ".map { customerInfo in", - expected: ".map { @Sendable customerInfo in" -) -testNoChange( - "no [weak self] added to fromAsync closure", - input: "Single.fromAsync { [userService] in" -) -test( - "existing [weak self] preserved exactly", - input: ".subscribe { [weak self] info in", - expected: ".subscribe { [weak self] @Sendable info in" -) - -// MARK: - Tests: file import guard - -print() -print("-- file import guard --") - -testBool( - "processes files with import RxSwift", - value: shouldProcessFile(text: "import UIKit\nimport RxSwift\nimport RIBs\n"), - expected: true -) -testBool( - "processes files with import RxRelay", - value: shouldProcessFile(text: "import RxRelay\n"), - expected: true -) -testBool( - "skips files with neither import", - value: shouldProcessFile(text: "import UIKit\nimport Foundation\n"), - expected: false -) -testBool( - "skips files with only import RIBs", - value: shouldProcessFile(text: "import RIBs\nimport UIKit\n"), - expected: false -) -testBool( - "skips Pods files even with import RxSwift", - value: shouldProcessFile(path: "/project/Pods/RxRelay/Observable+Bind.swift", text: "import RxSwift\n"), - expected: false -) -testBool( - "skips nested Pods path", - value: shouldProcessFile(path: "/project/Pods/RxSwift/RxSwift/Observable.swift", text: "import RxSwift\n"), - expected: false -) - -// MARK: - Tests: idempotency - -print() -print("-- idempotency --") - -let cases = [ - ".subscribe { response in", - ".subscribe { _ in", - ".map { value in", - ".filter { item in", -] -for input in cases { - let oncePassed = transform(input) - let twicePassed = transform(oncePassed) - test( - "idempotent: \(input.debugDescription)", - input: twicePassed, - expected: oncePassed - ) -} - -// MARK: - Summary - -print() -let total = passed + failed -print("\(total) test(s): \(passed) passed, \(failed) failed") -if failed > 0 { exit(1) } From 0b26cebb27d9bf8b15c77dce4f379c692739f779 Mon Sep 17 00:00:00 2001 From: Alex Bush Date: Tue, 7 Apr 2026 15:39:07 -0500 Subject: [PATCH 34/34] Complete migration to AST-based RxSendableMigrator - Remove obsolete V1 regex-based migration script and its tests. - Finalize RxSendableMigrator (AST-based) with comprehensive unit and integration tests. - Refactor rewriter to use IndexStoreProviding protocol for testability. - Add .swift-version and fix Package.swift formatting to ensure stable toolchain usage. - Add end-to-end integration test suite using Examples/RIBsAppExample2. --- .../IndexStoreDiscovery.swift | 4 +- .../IntegrationTests.swift | 139 ++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IntegrationTests.swift diff --git a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift index e894b73..024def4 100644 --- a/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreDiscovery.swift @@ -10,8 +10,8 @@ struct IndexStoreDiscovery { /// 2. Prefer entries whose `info.plist` WorkspacePath is related to `sourceDir` /// (i.e. the workspace lives inside or contains `sourceDir`). /// 3. Fall back to the most-recently-modified entry that has a valid DataStore. - static func findIndexStorePath(for sourceDir: String) -> String? { - let derivedDataURL = FileManager.default.homeDirectoryForCurrentUser + static func findIndexStorePath(for sourceDir: String, derivedDataURL: URL? = nil) -> String? { + let derivedDataURL = derivedDataURL ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Developer/Xcode/DerivedData") guard let entries = try? FileManager.default.contentsOfDirectory( diff --git a/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IntegrationTests.swift b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IntegrationTests.swift new file mode 100644 index 0000000..0ba353d --- /dev/null +++ b/tooling/RxSendableMigrator/Tests/RxSendableMigratorTests/IntegrationTests.swift @@ -0,0 +1,139 @@ +import XCTest +import Foundation +@testable import RxSendableMigrator + +/// Integration tests for RxSendableMigrator. +/// +/// These tests are slow because they require building a sample project with xcodebuild +/// to generate a real IndexStore. They are skipped by default unless RUN_INTEGRATION_TESTS=1 +/// environment variable is set. +final class IntegrationTests: XCTestCase { + + var tempDir: URL! + var sampleProjectDir: URL! + var projectRoot: URL! + var derivedDataPath: URL! + + override func setUpWithError() throws { + // Only run integration tests if explicitly requested, as they are slow and depend on Xcode environment + try XCTSkipIf(ProcessInfo.processInfo.environment["RUN_INTEGRATION_TESTS"] == nil, "Skipping integration tests. Set RUN_INTEGRATION_TESTS=1 to run.") + + tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("RxSendableMigratorIntegrationTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // Find the project root (walking up from this file) + projectRoot = URL(fileURLWithPath: #file) + .deletingLastPathComponent() // RxSendableMigratorTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // RxSendableMigrator + .deletingLastPathComponent() // tooling + .deletingLastPathComponent() // project root + + let sourceSampleDir = projectRoot.appendingPathComponent("Examples/RIBsAppExample2") + sampleProjectDir = tempDir.appendingPathComponent("RIBsAppExample2") + + // Copy sample project to temp dir + try runCommand("/bin/cp", arguments: ["-R", sourceSampleDir.path, sampleProjectDir.path]) + + // Patch project.pbxproj to use absolute path for RIBs-iOS dependency, + // so it can be built from the temporary directory. + let pbxprojPath = sampleProjectDir.appendingPathComponent("RIBsAppExample2.xcodeproj/project.pbxproj") + var pbxprojContent = try String(contentsOf: pbxprojPath, encoding: .utf8) + pbxprojContent = pbxprojContent.replacingOccurrences(of: "../../../RIBs-iOS", with: projectRoot.path) + try pbxprojContent.write(to: pbxprojPath, atomically: true, encoding: .utf8) + + derivedDataPath = tempDir.appendingPathComponent("DerivedData") + + print("Building sample project to generate IndexStore...") + // Build the sample project to generate IndexStore records. + // destination 'generic/platform=iOS' works even without physical device/simulator. + try runCommand("/usr/bin/xcodebuild", arguments: [ + "-project", sampleProjectDir.appendingPathComponent("RIBsAppExample2.xcodeproj").path, + "-scheme", "RIBsAppExample2", + "-destination", "generic/platform=iOS", + "-derivedDataPath", derivedDataPath.path, + "-clonedSourcePackagesDirPath", tempDir.appendingPathComponent("Packages").path, + "COMPILER_INDEX_STORE_ENABLE=YES", + "build", + "-quiet" + ]) + } + + override func tearDownWithError() throws { + if let tempDir = tempDir { + try? FileManager.default.removeItem(at: tempDir) + } + } + + func test_migrateSampleProject() throws { + let indexStorePath = IndexStoreDiscovery.findIndexStorePath(for: sampleProjectDir.path, derivedDataURL: derivedDataPath) + XCTAssertNotNil(indexStorePath, "Should have discovered an index store in \(derivedDataPath.path)") + + // Run the migrator logic + let arguments = [ + "rx-sendable-migrator", + "--source-dir", sampleProjectDir.path, + "--index-store-path", indexStorePath! + ] + + print("Running migrator on sample project...") + var migrator = try RxSendableMigrator.parseAsRoot(arguments) + try migrator.run() + + // Verify changes in a specific file + let interactorPath = sampleProjectDir.appendingPathComponent("RIBsAppExample2/Root/RootInteractor.swift") + let content = try String(contentsOf: interactorPath, encoding: .utf8) + + // Before: return firstViewableRIBActionableItemSubject.map { ($0, ()) } + // After: return firstViewableRIBActionableItemSubject.map { @Sendable ($0, ()) } + XCTAssertTrue(content.contains(".map { @Sendable ($0, ()) }"), "File should have been migrated: \(interactorPath.path)") + + // Verify subscribe was NOT migrated + XCTAssertTrue(content.contains(".subscribe { event in"), "Subscribe should NOT have been migrated") + XCTAssertFalse(content.contains(".subscribe { @Sendable event in"), "Subscribe should NOT have been migrated") + } + + func test_dryRunDoesNotModifyFiles() throws { + let indexStorePath = IndexStoreDiscovery.findIndexStorePath(for: sampleProjectDir.path, derivedDataURL: derivedDataPath) + XCTAssertNotNil(indexStorePath) + + let interactorPath = sampleProjectDir.appendingPathComponent("RIBsAppExample2/Root/RootInteractor.swift") + let originalContent = try String(contentsOf: interactorPath, encoding: .utf8) + + let arguments = [ + "rx-sendable-migrator", + "--source-dir", sampleProjectDir.path, + "--index-store-path", indexStorePath!, + "--dry-run" + ] + + var migrator = try RxSendableMigrator.parseAsRoot(arguments) + try migrator.run() + + let newContent = try String(contentsOf: interactorPath, encoding: .utf8) + XCTAssertEqual(originalContent, newContent, "Files should not be modified in dry-run mode") + } + + @discardableResult + private func runCommand(_ executable: String, arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + if process.terminationStatus != 0 { + throw NSError(domain: "IntegrationTests", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: "Command failed: \(executable) \(arguments.joined(separator: " "))\nOutput: \(output)"]) + } + + return output + } +}