diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml index d0929ad..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: @@ -19,17 +22,17 @@ 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 \ -scheme RIBs-Example \ -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.5' \ -enableCodeCoverage YES \ clean test 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 diff --git a/Example/Podfile b/Example/Podfile deleted file mode 100644 index 6c660d9..0000000 --- a/Example/Podfile +++ /dev/null @@ -1,8 +0,0 @@ -use_frameworks! - -platform :ios, '15.0' - -target 'RIBs_Example' do - pod 'RIBs', :path => '../', :testspecs => ['Tests'] - -end 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/Examples/Example1/Podfile b/Examples/Example1/Podfile new file mode 100644 index 0000000..07090ee --- /dev/null +++ b/Examples/Example1/Podfile @@ -0,0 +1,16 @@ +use_frameworks! + +platform :ios, '15.0' + +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 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..d684d1a --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2.xcodeproj/project.pbxproj @@ -0,0 +1,521 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* 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 */ + +/* 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 = ( + 7EA3A6F42F635A7100D01810 /* RIBs in Frameworks */, + 7EA3A6D62F62251D00D01810 /* RIBs in Frameworks */, + 7EFF41982F17D99D00B48704 /* RIBs in Frameworks */, + 7EB78D4F2F12CC0000547345 /* RIBs in Frameworks */, + 7E71E73E2F4426FA002FD889 /* 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 */, + 7EFF41972F17D99D00B48704 /* RIBs */, + 7E71E73D2F4426FA002FD889 /* RIBs */, + 7EA3A6D52F62251D00D01810 /* RIBs */, + 7EA3A6F32F635A7100D01810 /* 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 = ( + 7EA3A6F22F635A7100D01810 /* 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; + 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_VERSION = 6.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; + 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_VERSION = 6.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 */ + 7EA3A6F22F635A7100D01810 /* XCLocalSwiftPackageReference "../../../RIBs-iOS" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../RIBs-iOS"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7E71E73D2F4426FA002FD889 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + productName = RIBs; + }; + 7EA3A6D52F62251D00D01810 /* RIBs */ = { + isa = XCSwiftPackageProductDependency; + productName = RIBs; + }; + 7EA3A6F32F635A7100D01810 /* 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/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/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/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 new file mode 100644 index 0000000..8c23065 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBBuilder.swift @@ -0,0 +1,64 @@ +// +// 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, FourthViewableRIBDependency, MainRIBDependency { + + var actorService: ActorServicable { + ActorService() + } + + var rxSwiftService: RxSwiftServicable { + RxSwiftService() + } + + var secondViewableRIBBuilder: SecondViewableRIBBuildable { + SecondViewableRIBBuilder(dependency: self) + } + + 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 + +protocol FirstViewableRIBBuildable: Buildable { + func build(withListener listener: FirstViewableRIBListener) -> (routing: FirstViewableRIBRouting, actionableItem: FirstViewableRIBActionableItem) +} + +final class FirstViewableRIBBuilder: Builder, FirstViewableRIBBuildable { + + override init(dependency: FirstViewableRIBDependency) { + super.init(dependency: dependency) + } + + 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, authService: component.authService) + interactor.listener = listener + 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 new file mode 100644 index 0000000..7a2d37b --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBInteractor.swift @@ -0,0 +1,140 @@ +// +// FirstViewableRIBInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import Foundation + + +protocol FirstViewableRIBRouting: ViewableRouting { + var firstViewableRIBViewController: FirstViewableRIBViewControllable { get } + func routeToSecondViewableRIB() + func routeAwayFromSecondViewableRIB() + func routeToFourthViewableRIB() -> FourthViewableRIBActionableItem + func routeAwayFromFourthViewableRIB() + func routeToMainRIB(userSession: UserSession) + func routeAwayFromMainRIB() +} + +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? + + private let actorService: ActorServicable + private let rxSwiftService: RxSwiftServicable + private let authService: AuthServiceType + + init(presenter: FirstViewableRIBPresentable, actorService: ActorServicable, rxSwiftService: RxSwiftServicable, authService: AuthServiceType) { + self.actorService = actorService + self.rxSwiftService = rxSwiftService + self.authService = authService + 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: { _ 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() + } + + // MARK: - FirstViewableRIBPresentableListener + + func login() { + Single.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, ())> { + 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 new file mode 100644 index 0000000..cf23295 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBRouter.swift @@ -0,0 +1,95 @@ +// +// FirstViewableRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol FirstViewableRIBInteractable: Interactable, FirstViewableRIBActionableItem, SecondViewableRIBListener, FourthViewableRIBListener, MainRIBListener { + 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? + + private let fourthViewableRIBBuilder: FourthViewableRIBBuildable + private var fourthViewableRIBRouter: FourthViewableRIBRouting? + + private let mainRIBBuilder: MainRIBBuildable + private var mainRIBRouter: MainRIBRouting? + + init(interactor: FirstViewableRIBInteractable, viewController: FirstViewableRIBViewControllable, secondViewableRIBBuilder: SecondViewableRIBBuildable, fourthViewableRIBBuilder: FourthViewableRIBBuildable, mainRIBBuilder: MainRIBBuildable) { + self.secondViewableRIBBuilder = secondViewableRIBBuilder + self.fourthViewableRIBBuilder = fourthViewableRIBBuilder + self.mainRIBBuilder = mainRIBBuilder + 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) + } + } + + func routeToFourthViewableRIB() -> 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) + } + } + + func routeToMainRIB(userSession: UserSession) { + let mainRIBRouter = mainRIBBuilder.build( + withDynamicBuildDependency: interactor, + dynamicComponentDependency: userSession + ) + self.mainRIBRouter = mainRIBRouter + viewController.uiviewController.navigationController?.pushViewController( + mainRIBRouter.viewControllable.uiviewController, animated: true + ) + attachChild(mainRIBRouter) + } + + func routeAwayFromMainRIB() { + if let mainRIBRouter = mainRIBRouter { + self.mainRIBRouter = nil + viewController.uiviewController.navigationController?.popToViewController( + viewController.uiviewController, animated: true + ) + detachChild(mainRIBRouter) + } + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift new file mode 100644 index 0000000..d559969 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FirstViewableRIB/FirstViewableRIBViewController.swift @@ -0,0 +1,47 @@ +// +// FirstViewableRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import UIKit + +protocol FirstViewableRIBPresentableListener: AnyObject { + func login() +} + +final class FirstViewableRIBViewController: UIViewController, FirstViewableRIBPresentable, FirstViewableRIBViewControllable { + + weak var listener: FirstViewableRIBPresentableListener? + + private let loginButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Login", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGreen + title = "First" + setupViews() + } + + private func setupViews() { + view.addSubview(loginButton) + NSLayoutConstraint.activate([ + loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside) + } + + @objc private func loginTapped() { + listener?.login() + } +} 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/FourthViewableRIB/FourthViewableRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift new file mode 100644 index 0000000..6cfb884 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/FourthViewableRIB/FourthViewableRIBBuilder.swift @@ -0,0 +1,41 @@ +// +// 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) -> (routing: FourthViewableRIBRouting, actionableItem: FourthViewableRIBActionableItem) +} + +final class FourthViewableRIBBuilder: Builder, FourthViewableRIBBuildable { + + override init(dependency: FourthViewableRIBDependency) { + super.init(dependency: dependency) + } + + 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 + let router = FourthViewableRIBRouter(interactor: interactor, viewController: viewController) + return (routing: router, actionableItem: interactor) + } +} 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..4bb760b --- /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, FourthViewableRIBActionableItem { + 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/HomeRIB/HomeRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBBuilder.swift new file mode 100644 index 0000000..dd0b466 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/HomeRIB/HomeRIBBuilder.swift @@ -0,0 +1,46 @@ +// +// HomeRIBBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/12/26. +// + +import RIBs + +// MARK: - Dependency + +protocol HomeRIBDependency: Dependency { + var currentUserService: CurrentUserServiceType { get } +} + +// MARK: - Component + +final class HomeRIBComponent: Component { + + 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/Info.plist b/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist new file mode 100644 index 0000000..e0c227b --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleURLTypes + + + CFBundleURLSchemes + + ribsappexample2 + + CFBundleURLName + RIBsAppExample2 Deep Link + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + 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/Root/RootBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift new file mode 100644 index 0000000..fb1b7ed --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootBuilder.swift @@ -0,0 +1,46 @@ +// +// 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 + +struct RootBuildResult { + let launchRouter: LaunchRouting + let urlHandler: UrlHandler +} + +protocol RootBuildable: Buildable { + func build() -> RootBuildResult +} + +final class RootBuilder: Builder, RootBuildable { + + override init(dependency: RootDependency) { + super.init(dependency: dependency) + } + + func build() -> RootBuildResult { + let component = RootComponent(dependency: dependency) + let viewController = RootViewController() + let interactor = RootInteractor(presenter: viewController) + 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 new file mode 100644 index 0000000..609e579 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootInteractor.swift @@ -0,0 +1,70 @@ +// +// RootInteractor.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import Foundation + +protocol RootRouting: ViewableRouting { + func routeToFirstViewableRIB() -> FirstViewableRIBActionableItem + 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, 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) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + 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 new file mode 100644 index 0000000..b581397 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/Root/RootRouter.swift @@ -0,0 +1,48 @@ +// +// RootRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol RootInteractable: Interactable, FirstViewableRIBListener, RootActionableItem { + 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() -> 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 + 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..75d5620 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SceneDelegate.swift @@ -0,0 +1,37 @@ +// +// 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? + 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 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/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 new file mode 100644 index 0000000..c2eea8e --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBBuilder.swift @@ -0,0 +1,50 @@ +// +// 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, ThirdHeadlessRIBDependency { + + var thirdHeadlessRIBBuilder: ThirdHeadlessRIBBuildable { + ThirdHeadlessRIBBuilder(dependency: self) + } + + var viewController: SecondViewableRIBPresentable & SecondViewableRIBViewControllable { + shared { SecondViewableRIBViewController() } + } + + var thirdHeadlessRIBViewController: any ThirdHeadlessRIBViewControllable { + viewController + } +} + +// 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 = component.viewController + let exampleWorker = ExampleWorkerImp() + let interactor = SecondViewableRIBInteractor(presenter: viewController, exampleWorker: exampleWorker) + interactor.listener = listener + 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 new file mode 100644 index 0000000..eb00755 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBInteractor.swift @@ -0,0 +1,55 @@ +// +// 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 { + func didComplete(_ secondViewableRIB: SecondViewableRIBInteractable) +} + +final class SecondViewableRIBInteractor: PresentableInteractor, SecondViewableRIBInteractable, SecondViewableRIBPresentableListener { + + weak var router: SecondViewableRIBRouting? + weak var listener: SecondViewableRIBListener? + + private let exampleWorker: ExampleWorker + + init(presenter: SecondViewableRIBPresentable, exampleWorker: ExampleWorker) { + self.exampleWorker = exampleWorker + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + exampleWorker.start(self) + } + + func close() { + listener?.didComplete(self) + } + + override func willResignActive() { + super.willResignActive() + + print("SecondViewableRIBInteractor willResignActive") + } + + func sendData(_ interactor: any ThirdHeadlessRIBInteractable) { + print("data received in the SecondViewableRIBInteractor from ThirdHeadlessRIBInteractable") + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift new file mode 100644 index 0000000..df777d5 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBRouter.swift @@ -0,0 +1,45 @@ +// +// SecondViewableRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs + +protocol SecondViewableRIBInteractable: Interactable, ThirdHeadlessRIBListener { + var router: SecondViewableRIBRouting? { get set } + var listener: SecondViewableRIBListener? { get set } +} + +protocol SecondViewableRIBViewControllable: ViewControllable, ThirdHeadlessRIBViewControllable { + +} + +final class SecondViewableRIBRouter: ViewableRouter, SecondViewableRIBRouting { + + private let thirdHeadlessRIBBuilder: ThirdHeadlessRIBBuildable + private var thirdHeadlessRIBRouter: ThirdHeadlessRIBRouting? + + init(interactor: SecondViewableRIBInteractable, viewController: SecondViewableRIBViewControllable, thirdHeadlessRIBBuilder: ThirdHeadlessRIBBuildable) { + self.thirdHeadlessRIBBuilder = thirdHeadlessRIBBuilder + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } + + var secondViewableRIBViewController: any SecondViewableRIBViewControllable { + viewController + } + + override func didLoad() { + super.didLoad() + + routeToThirdHeadlessRIB() + } + + private func routeToThirdHeadlessRIB() { + let thirdHeadlessRIBRouter = thirdHeadlessRIBBuilder.build(withListener: interactor) + self.thirdHeadlessRIBRouter = thirdHeadlessRIBRouter + attachChild(thirdHeadlessRIBRouter) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift new file mode 100644 index 0000000..8f88315 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/SecondViewableRIB/SecondViewableRIBViewController.swift @@ -0,0 +1,40 @@ +// +// SecondViewableRIBViewController.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 1/10/26. +// + +import RIBs +import RxSwift +import UIKit + +protocol SecondViewableRIBPresentableListener: AnyObject { + func close() +} + +final class SecondViewableRIBViewController: UIViewController, SecondViewableRIBPresentable, SecondViewableRIBViewControllable { + + weak var listener: SecondViewableRIBPresentableListener? + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .blue + setupBackButton() + } + + private func setupBackButton() { + let backButton = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(didTapBack) + ) + navigationItem.leftBarButtonItem = backButton + } + + @objc private func didTapBack() { + listener?.close() + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift new file mode 100644 index 0000000..02049f9 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBBuilder.swift @@ -0,0 +1,46 @@ +// +// ThirdHeadlessRIBBuilder.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs + +protocol ThirdHeadlessRIBDependency: Dependency { + + var thirdHeadlessRIBViewController: ThirdHeadlessRIBViewControllable { get } + // TODO: Declare the set of dependencies required by this RIB, but won't be + // created by this RIB. +} + +final class ThirdHeadlessRIBComponent: Component, FourthViewableRIBDependency { + + fileprivate var thirdHeadlessRIBViewController: ThirdHeadlessRIBViewControllable { + return dependency.thirdHeadlessRIBViewController + } + + fileprivate var fourthViewableRIBBuilder: FourthViewableRIBBuildable { + FourthViewableRIBBuilder(dependency: self) + } +} + +// 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, fourthViewableRIBBuilder: component.fourthViewableRIBBuilder) + } +} diff --git a/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift new file mode 100644 index 0000000..4c00751 --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBInteractor.swift @@ -0,0 +1,54 @@ +// +// 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. + func routeToFourthRIB() + func routeAwayFromFourthRIB() +} + +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) + self.router?.routeToFourthRIB() + }) + .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..6ca3acb --- /dev/null +++ b/Examples/RIBsAppExample2/RIBsAppExample2/ThirdHeadlessRIB/ThirdHeadlessRIBRouter.swift @@ -0,0 +1,58 @@ +// +// ThirdHeadlessRIBRouter.swift +// RIBsAppExample2 +// +// Created by Alex Bush on 3/10/26. +// + +import RIBs + +protocol ThirdHeadlessRIBInteractable: Interactable, FourthViewableRIBListener { + 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 { + + 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, fourthViewableRIBBuilder: FourthViewableRIBBuildable) { + self.viewController = viewController + self.fourthViewableRIBBuilder = fourthViewableRIBBuilder + 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. + routeAwayFromFourthRIB() + } + + + func routeToFourthRIB() { + let (fourthViewableRIBRouter, fourthViewableRIBInteractor) = 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) + } + } +} 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 +} 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/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) +} 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. + } + } + +} 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/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' 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..d123c79 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. @@ -139,7 +142,7 @@ open class Interactor: Interactable { private let isActiveSubject = BehaviorSubject(value: false) fileprivate var activenessDisposable: CompositeDisposable? - deinit { + isolated deinit { if isActive { deactivate() } @@ -162,7 +165,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 +197,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/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/PresentableInteractor.swift b/RIBs/Classes/PresentableInteractor.swift index bafdccd..38e9b98 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`. @@ -33,7 +34,7 @@ open class PresentableInteractor: Interactor { // MARK: - Private - deinit { + isolated deinit { LeakDetector.instance.expectDeallocate(object: presenter as AnyObject) } } 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/Router.swift b/RIBs/Classes/Router.swift index de593d0..1371702 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`. @@ -210,18 +214,19 @@ open class Router: Routing { detachChild(child) } } - - deinit { + + isolated deinit { interactable.deactivate() - + if !children.isEmpty { detachAllChildren() } - + lifecycleSubject.onCompleted() - + deinitDisposable.dispose() - + LeakDetector.instance.expectDeallocate(object: interactable) + } } 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 82488b4..e562cc3 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`. @@ -89,7 +91,7 @@ open class ViewableRouter: Router Disposable { if let compositeDisposable = worker.disposable { _ = compositeDisposable.insert(self) @@ -199,6 +201,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..40f1e2a 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() @@ -206,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) } @@ -220,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) } 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() { 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+) | 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.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..90e34cb --- /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..024def4 --- /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, derivedDataURL: URL? = nil) -> String? { + let derivedDataURL = 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..18cbefb --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/IndexStoreService.swift @@ -0,0 +1,102 @@ +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." + } +} + +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 { + 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..69f4452 --- /dev/null +++ b/tooling/RxSendableMigrator/Sources/RxSendableMigrator/RxSendableRewriter.swift @@ -0,0 +1,168 @@ +import Foundation +import SwiftSyntax + +final class RxSendableRewriter: SyntaxRewriter { + let filePath: String + let locationConverter: SourceLocationConverter + let indexService: IndexStoreProviding + + // 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: IndexStoreProviding, 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 } + + // 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 { + // 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/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 + } +} 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/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 + } +}