├── .github └── workflows │ ├── build_parsers.yml │ ├── cli_deploy.yml │ └── cli_tests.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── emerge_cli.gemspec ├── exe └── emerge ├── lib ├── commands │ ├── build │ │ └── distribution │ │ │ ├── install.rb │ │ │ └── validate.rb │ ├── config │ │ ├── orderfiles │ │ │ └── orderfiles_ios.rb │ │ └── snapshots │ │ │ └── snapshots_ios.rb │ ├── fix │ │ ├── exported_symbols.rb │ │ ├── minify_strings.rb │ │ └── strip_binary_symbols.rb │ ├── global_options.rb │ ├── integrate │ │ └── fastlane.rb │ ├── order_files │ │ ├── download_order_files.rb │ │ ├── validate_linkmaps.rb │ │ └── validate_xcode_project.rb │ ├── reaper │ │ └── reaper.rb │ ├── snapshots │ │ └── validate_app.rb │ └── upload │ │ ├── build.rb │ │ └── snapshots │ │ ├── client_libraries │ │ ├── default.rb │ │ ├── paparazzi.rb │ │ ├── roborazzi.rb │ │ └── swift_snapshot_testing.rb │ │ └── snapshots.rb ├── emerge_cli.rb ├── reaper │ ├── ast_parser.rb │ └── code_deleter.rb ├── utils │ ├── environment.rb │ ├── git.rb │ ├── git_info_provider.rb │ ├── git_result.rb │ ├── github.rb │ ├── logger.rb │ ├── macho_parser.rb │ ├── network.rb │ ├── profiler.rb │ ├── project_detector.rb │ ├── version_check.rb │ ├── xcode_device_manager.rb │ ├── xcode_physical_device.rb │ └── xcode_simulator.rb └── version.rb ├── parsers.toml ├── parsers ├── libtree-sitter-java-darwin-arm64.dylib ├── libtree-sitter-java-linux-x86_64.so ├── libtree-sitter-kotlin-darwin-arm64.dylib ├── libtree-sitter-kotlin-linux-x86_64.so ├── libtree-sitter-objc-darwin-arm64.dylib ├── libtree-sitter-objc-linux-x86_64.so ├── libtree-sitter-swift-darwin-arm64.dylib └── libtree-sitter-swift-linux-x86_64.so └── test ├── commands ├── autofixes │ ├── exported_symbols_test.rb │ ├── minify_strings_test.rb │ └── strip_binary_symbols_test.rb ├── config │ ├── orderfiles │ │ └── orderfiles_ios_test.rb │ └── snapshots │ │ └── snapshots_ios_test.rb ├── orderfiles │ └── download_order_files_test.rb ├── snapshots │ └── validate_app_test.rb └── upload │ └── snapshots │ ├── client_libraries │ ├── paparazzi_test.rb │ ├── roborazzi_test.rb │ └── swift_snapshot_testing_test.rb │ └── snapshots_test.rb ├── fixtures └── reaper │ └── objc │ ├── EMGURLProtocol.h │ ├── EMGURLProtocol.m │ └── test_removes_type_from_objective_c_file │ └── EMGURLProtocol.m ├── reaper └── ast_parser_test.rb ├── support ├── fake_git_info_provider.rb └── fake_network.rb ├── test_files ├── ExampleApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcuserdata │ │ │ └── itaybrenner.xcuserdatad │ │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ │ └── itaybrenner.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist ├── TestBinaryNoPreviews.xcarchive │ ├── Info.plist │ └── Products │ │ └── Applications │ │ └── TestBinary.app │ │ ├── Info.plist │ │ └── TestBinary ├── TestBinaryWithChainFixups.xcarchive │ ├── Info.plist │ └── Products │ │ └── Applications │ │ └── TestBinary.app │ │ ├── Info.plist │ │ └── TestBinary ├── TestBinaryWithoutChainFixups.xcarchive │ ├── Info.plist │ └── Products │ │ └── Applications │ │ └── TestBinary.app │ │ ├── Info.plist │ │ └── TestBinary └── com.emerge.hn.Hacker-News-3.4.0.gz ├── test_helper.rb └── utils ├── version_check_test.rb └── xcode_device_manager_test.rb /.github/workflows/build_parsers.yml: -------------------------------------------------------------------------------- 1 | name: Build Tree-Sitter Parsers 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - telkins/objc 8 | paths: 9 | - "parsers.toml" 10 | - ".github/workflows/build_parsers.yml" 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build-parsers: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | include: 19 | - os: ubuntu-latest 20 | platform: linux 21 | - os: macos-latest 22 | platform: macos 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v3 27 | 28 | - name: Install Rust toolchain 29 | uses: actions-rs/toolchain@v1 30 | with: 31 | toolchain: stable 32 | override: true 33 | 34 | - name: Install tsdl 35 | run: | 36 | cargo install tsdl 37 | 38 | - name: Build tree-sitter parsers 39 | run: | 40 | tsdl build --out-dir build_parsers 41 | 42 | - name: Determine platform and architecture 43 | id: platform_info 44 | run: | 45 | # Determine architecture 46 | arch=$(uname -m) 47 | # Map architecture names to standard values 48 | if [[ "$arch" == "x86_64" || "$arch" == "amd64" ]]; then 49 | arch="x86_64" 50 | elif [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then 51 | arch="arm64" 52 | fi 53 | 54 | # For macOS, adjust platform name 55 | if [[ "${{ matrix.platform }}" == "macos" ]]; then 56 | platform_name="darwin" 57 | else 58 | platform_name="${{ matrix.platform }}" 59 | fi 60 | 61 | # Set outputs 62 | echo "arch=$arch" >> $GITHUB_OUTPUT 63 | echo "platform_name=$platform_name" >> $GITHUB_OUTPUT 64 | 65 | - name: Rename built parsers to include platform and architecture 66 | run: | 67 | # Use outputs from previous step 68 | arch="${{ steps.platform_info.outputs.arch }}" 69 | platform_name="${{ steps.platform_info.outputs.platform_name }}" 70 | platform_arch="${platform_name}-${arch}" 71 | 72 | echo "Using platform_arch=${platform_arch}" 73 | 74 | # Rename parser files in the temporary directory 75 | for file in build_parsers/libtree-sitter-*; do 76 | if [ -f "$file" ]; then 77 | filename=$(basename "$file") 78 | extension="${filename##*.}" 79 | base="${filename%.*}" 80 | 81 | echo "Processing file $filename" 82 | 83 | # Append platform_arch before the extension 84 | new_base="${base}-${platform_arch}" 85 | echo "Renaming $file to build_parsers/${new_base}.${extension}" 86 | mv "$file" "build_parsers/${new_base}.${extension}" 87 | fi 88 | done 89 | 90 | - name: Move renamed parsers to parsers directory 91 | run: | 92 | # Ensure parsers directory exists 93 | mkdir -p parsers 94 | 95 | # Move files and overwrite existing ones 96 | for file in build_parsers/*; do 97 | filename=$(basename "$file") 98 | dest_file="parsers/$filename" 99 | 100 | echo "Moving $file to $dest_file" 101 | mv -f "$file" "$dest_file" 102 | done 103 | 104 | - name: Configure git 105 | run: | 106 | git config user.name "${{ github.actor }}" 107 | git config user.email "${{ github.actor }}@users.noreply.github.com" 108 | 109 | - name: Check for changes 110 | id: changes 111 | run: | 112 | git add -A 113 | if git diff --cached --quiet; then 114 | echo "changes=false" >> $GITHUB_OUTPUT 115 | else 116 | echo "changes=true" >> $GITHUB_OUTPUT 117 | fi 118 | 119 | - name: Pull latest changes 120 | if: steps.changes.outputs.changes == 'true' 121 | run: | 122 | git pull --rebase --autostash 123 | 124 | - name: Commit changes 125 | if: steps.changes.outputs.changes == 'true' 126 | run: | 127 | git commit -m "Build tree-sitter parsers for ${{ steps.platform_info.outputs.platform_name }}-${{ steps.platform_info.outputs.arch }}" 128 | 129 | - name: Push changes 130 | if: steps.changes.outputs.changes == 'true' 131 | run: | 132 | max_retries=5 133 | retry_count=0 134 | until git push || [ $retry_count -eq $max_retries ]; do 135 | echo "Push failed, retrying..." 136 | git pull --rebase --autostash 137 | retry_count=$((retry_count + 1)) 138 | sleep 5 139 | done 140 | if [ $retry_count -eq $max_retries ]; then 141 | echo "Push failed after $max_retries attempts." 142 | exit 1 143 | fi 144 | -------------------------------------------------------------------------------- /.github/workflows/cli_deploy.yml: -------------------------------------------------------------------------------- 1 | name: RubyGems Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [ published ] 7 | 8 | jobs: 9 | build: 10 | name: Build and Deploy CLI 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 14 | contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Ruby and install dependencies 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: '3.3.3' 23 | bundler-cache: true 24 | 25 | - name: Install dependencies 26 | run: | 27 | gem install bundler 28 | bundle install 29 | 30 | - name: Build and install gem locally 31 | run: | 32 | bundle exec rake build 33 | gem install pkg/*.gem 34 | 35 | - name: Test emerge CLI 36 | run: | 37 | # Update PATH to include both system and user gem paths 38 | export PATH="$(ruby -e 'puts Gem.user_dir')/bin:$(ruby -e 'puts Gem.dir')/bin:$PATH" 39 | 40 | OUTPUT=$(emerge -h 2>&1 || true) 41 | echo "$OUTPUT" 42 | 43 | # Check for expected strings 44 | echo "$OUTPUT" | grep -q "emerge integrate \[SUBCOMMAND\]" || { echo "Expected integrate command not found"; exit 1; } 45 | echo "$OUTPUT" | grep -q "emerge upload \[SUBCOMMAND\]" || { echo "Expected upload command not found"; exit 1; } 46 | 47 | - name: Release Gem 48 | uses: rubygems/release-gem@v1 49 | -------------------------------------------------------------------------------- /.github/workflows/cli_tests.yml: -------------------------------------------------------------------------------- 1 | name: CLI Tests and Linting 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: "3.3.3" 22 | bundler-cache: true 23 | 24 | - name: Run Rubocop 25 | run: bundle exec rubocop --parallel 26 | 27 | test: 28 | name: Test 29 | strategy: 30 | matrix: 31 | os: [ubuntu-latest, macos-latest] 32 | ruby-version: ["3.3.3"] 33 | runs-on: ${{ matrix.os }} 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: Set up Ruby 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: ${{ matrix.ruby-version }} 44 | bundler-cache: true 45 | 46 | - name: Run Minitest tests 47 | run: | 48 | bundle exec rake test TESTOPTS="--verbose" 49 | env: 50 | MINITEST_REPORTER: ProgressReporter 51 | 52 | integration: 53 | name: CLI Integration 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v4 57 | with: 58 | fetch-depth: 0 59 | 60 | - name: Set up Ruby 61 | uses: ruby/setup-ruby@v1 62 | with: 63 | ruby-version: "3.3.3" 64 | bundler-cache: true 65 | 66 | - name: Install dependencies 67 | run: | 68 | if [ "$RUNNER_OS" == "Linux" ]; then 69 | sudo apt-get update 70 | sudo apt-get install -y imagemagick 71 | elif [ "$RUNNER_OS" == "macOS" ]; then 72 | brew install imagemagick 73 | fi 74 | shell: bash 75 | 76 | - name: Create test images directory 77 | run: | 78 | mkdir -p test/fixtures/snapshots 79 | # Create a simple test PNG file 80 | convert -size 100x100 xc:white test/fixtures/snapshots/test1.png 81 | convert -size 200x200 xc:blue test/fixtures/snapshots/test2.png 82 | 83 | - name: Run CLI command 84 | env: 85 | EMERGE_API_TOKEN: ${{ secrets.EMERGE_API_TOKEN }} 86 | run: | 87 | bundle exec ruby exe/emerge upload snapshots \ 88 | --name "Emerge CLI Test" \ 89 | --id "com.emerge-cli.integration" \ 90 | --repo-name "EmergeTools/emerge-cli" \ 91 | --debug \ 92 | --profile \ 93 | --batch \ 94 | test/fixtures/snapshots 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SimpleCov 2 | coverage/ 3 | 4 | # RSpec 5 | spec/examples.txt 6 | 7 | # Rake tasks 8 | .rake_tasks~ 9 | 10 | # Bundler 11 | .bundle/ 12 | 13 | # Yardoc 14 | .yardoc 15 | /_yardoc/ 16 | 17 | # Documentation 18 | /doc/ 19 | 20 | # Packages 21 | /pkg/ 22 | 23 | # Reports 24 | /spec/reports/ 25 | 26 | # Temporary files 27 | /tmp/ 28 | 29 | # Gems 30 | *.gem 31 | 32 | # Gemfile lock 33 | Gemfile.lock 34 | 35 | # DS_Store 36 | .DS_Store 37 | 38 | # Ignore build files downloaded for testing 39 | *.apk 40 | *.ipa 41 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Gemspec 2 | 3 | Gemspec/AddRuntimeDependency: 4 | Enabled: true 5 | 6 | Gemspec/DeprecatedAttributeAssignment: 7 | Enabled: true 8 | 9 | Gemspec/DevelopmentDependencies: 10 | Enabled: true 11 | 12 | Gemspec/RequireMFA: 13 | Enabled: true 14 | 15 | # Layout 16 | 17 | Layout/LineLength: 18 | Max: 120 19 | 20 | Layout/EmptyLineAfterGuardClause: 21 | Enabled: false 22 | 23 | Layout/LineContinuationLeadingSpace: 24 | Enabled: true 25 | 26 | Layout/LineContinuationSpacing: 27 | Enabled: true 28 | 29 | Layout/LineEndStringConcatenationIndentation: 30 | Enabled: true 31 | 32 | Layout/SpaceBeforeBrackets: 33 | Enabled: true 34 | 35 | # Lint 36 | 37 | Lint/DuplicateMethods: 38 | Enabled: false 39 | 40 | Lint/MissingSuper: 41 | Enabled: false 42 | 43 | Lint/AmbiguousAssignment: 44 | Enabled: true 45 | 46 | Lint/AmbiguousOperatorPrecedence: 47 | Enabled: true 48 | 49 | Lint/AmbiguousRange: 50 | Enabled: true 51 | 52 | Lint/ConstantOverwrittenInRescue: 53 | Enabled: true 54 | 55 | Lint/DeprecatedConstants: 56 | Enabled: true 57 | 58 | Lint/DuplicateBranch: 59 | Enabled: true 60 | 61 | Lint/DuplicateMagicComment: 62 | Enabled: true 63 | 64 | Lint/DuplicateMatchPattern: 65 | Enabled: true 66 | 67 | Lint/DuplicateRegexpCharacterClassElement: 68 | Enabled: true 69 | 70 | Lint/DuplicateSetElement: 71 | Enabled: true 72 | 73 | Lint/EmptyBlock: 74 | Enabled: true 75 | 76 | Lint/EmptyClass: 77 | Enabled: true 78 | 79 | Lint/EmptyInPattern: 80 | Enabled: true 81 | 82 | Lint/IncompatibleIoSelectWithFiberScheduler: 83 | Enabled: true 84 | 85 | Lint/ItWithoutArgumentsInBlock: 86 | Enabled: true 87 | 88 | Lint/LambdaWithoutLiteralBlock: 89 | Enabled: true 90 | 91 | Lint/LiteralAssignmentInCondition: 92 | Enabled: true 93 | 94 | Lint/MixedCaseRange: 95 | Enabled: true 96 | 97 | Lint/NoReturnInBeginEndBlocks: 98 | Enabled: true 99 | 100 | Lint/NonAtomicFileOperation: 101 | Enabled: true 102 | 103 | Lint/NumberedParameterAssignment: 104 | Enabled: true 105 | 106 | Lint/OrAssignmentToConstant: 107 | Enabled: true 108 | 109 | Lint/RedundantDirGlobSort: 110 | Enabled: true 111 | 112 | Lint/RedundantRegexpQuantifiers: 113 | Enabled: true 114 | 115 | Lint/RefinementImportMethods: 116 | Enabled: true 117 | 118 | Lint/RequireRangeParentheses: 119 | Enabled: true 120 | 121 | Lint/RequireRelativeSelfPath: 122 | Enabled: true 123 | 124 | Lint/SymbolConversion: 125 | Enabled: true 126 | 127 | Lint/ToEnumArguments: 128 | Enabled: true 129 | 130 | Lint/TripleQuotes: 131 | Enabled: true 132 | 133 | Lint/UnescapedBracketInRegexp: 134 | Enabled: true 135 | 136 | Lint/UnexpectedBlockArity: 137 | Enabled: true 138 | 139 | Lint/UnmodifiedReduceAccumulator: 140 | Enabled: true 141 | 142 | Lint/UselessNumericOperation: 143 | Enabled: true 144 | 145 | Lint/UselessRescue: 146 | Enabled: true 147 | 148 | Lint/UselessRuby2Keywords: 149 | Enabled: true 150 | 151 | # Metrics 152 | 153 | Metrics/AbcSize: 154 | Enabled: false 155 | 156 | Metrics/ClassLength: 157 | Enabled: false 158 | 159 | Metrics/CyclomaticComplexity: 160 | Enabled: false 161 | 162 | Metrics/MethodLength: 163 | Enabled: false 164 | 165 | Metrics/ParameterLists: 166 | Max: 10 167 | 168 | Metrics/PerceivedComplexity: 169 | Enabled: false 170 | 171 | Metrics/BlockLength: 172 | Enabled: false 173 | 174 | Metrics/ModuleLength: 175 | Max: 300 176 | 177 | Metrics/CollectionLiteralLength: 178 | Enabled: true 179 | 180 | # Naming 181 | 182 | Naming/AccessorMethodName: 183 | Enabled: false 184 | 185 | Naming/MethodParameterName: 186 | Enabled: false 187 | 188 | Naming/BlockForwarding: 189 | Enabled: true 190 | 191 | # Security 192 | 193 | Security/CompoundHash: 194 | Enabled: true 195 | 196 | Security/IoMethods: 197 | Enabled: true 198 | 199 | # Style 200 | 201 | Style/Documentation: 202 | Enabled: false 203 | 204 | Style/FormatString: 205 | Enabled: false 206 | 207 | Style/FrozenStringLiteralComment: 208 | Enabled: false 209 | 210 | Style/GlobalStdStream: 211 | Enabled: false 212 | 213 | Style/GlobalVars: 214 | Enabled: false 215 | 216 | Style/AccessorGrouping: 217 | Enabled: false 218 | 219 | Style/InverseMethods: 220 | Enabled: false 221 | 222 | Style/MultilineBlockChain: 223 | Enabled: false 224 | 225 | Style/NegatedIf: 226 | Enabled: false 227 | 228 | Style/NumericPredicate: 229 | Enabled: false 230 | 231 | Style/RedundantCapitalW: 232 | Enabled: false 233 | 234 | Style/RedundantParentheses: 235 | Enabled: false 236 | 237 | Style/TrivialAccessors: 238 | Enabled: false 239 | 240 | Style/ZeroLengthPredicate: 241 | Enabled: false 242 | 243 | Style/AmbiguousEndlessMethodDefinition: 244 | Enabled: true 245 | 246 | Style/ArgumentsForwarding: 247 | Enabled: true 248 | 249 | Style/ArrayIntersect: 250 | Enabled: true 251 | 252 | Style/BitwisePredicate: 253 | Enabled: true 254 | 255 | Style/CollectionCompact: 256 | Enabled: true 257 | 258 | Style/CombinableDefined: 259 | Enabled: true 260 | 261 | Style/ComparableClamp: 262 | Enabled: true 263 | 264 | Style/ConcatArrayLiterals: 265 | Enabled: true 266 | 267 | Style/DataInheritance: 268 | Enabled: true 269 | 270 | Style/DirEmpty: 271 | Enabled: true 272 | 273 | Style/DocumentDynamicEvalDefinition: 274 | Enabled: true 275 | 276 | Style/EmptyHeredoc: 277 | Enabled: true 278 | 279 | Style/EndlessMethod: 280 | Enabled: true 281 | 282 | Style/EnvHome: 283 | Enabled: true 284 | 285 | Style/ExactRegexpMatch: 286 | Enabled: true 287 | 288 | Style/FetchEnvVar: 289 | Enabled: true 290 | 291 | Style/FileEmpty: 292 | Enabled: true 293 | 294 | Style/FileRead: 295 | Enabled: true 296 | 297 | Style/FileWrite: 298 | Enabled: true 299 | 300 | Style/HashConversion: 301 | Enabled: true 302 | 303 | Style/HashExcept: 304 | Enabled: true 305 | 306 | Style/IfWithBooleanLiteralBranches: 307 | Enabled: true 308 | 309 | Style/InPatternThen: 310 | Enabled: true 311 | 312 | Style/KeywordArgumentsMerging: 313 | Enabled: true 314 | 315 | Style/MagicCommentFormat: 316 | Enabled: true 317 | 318 | Style/MapCompactWithConditionalBlock: 319 | Enabled: true 320 | 321 | Style/MapIntoArray: 322 | Enabled: true 323 | 324 | Style/MapToHash: 325 | Enabled: true 326 | 327 | Style/MapToSet: 328 | Enabled: true 329 | 330 | Style/MinMaxComparison: 331 | Enabled: true 332 | 333 | Style/MultilineInPatternThen: 334 | Enabled: true 335 | 336 | Style/NegatedIfElseCondition: 337 | Enabled: true 338 | 339 | Style/NestedFileDirname: 340 | Enabled: true 341 | 342 | Style/NilLambda: 343 | Enabled: true 344 | 345 | Style/NumberedParameters: 346 | Enabled: true 347 | 348 | Style/NumberedParametersLimit: 349 | Enabled: true 350 | 351 | Style/ObjectThen: 352 | Enabled: true 353 | 354 | Style/OpenStructUse: 355 | Enabled: true 356 | 357 | Style/OperatorMethodCall: 358 | Enabled: true 359 | 360 | Style/QuotedSymbols: 361 | Enabled: true 362 | 363 | Style/RedundantArgument: 364 | Enabled: true 365 | 366 | Style/RedundantArrayConstructor: 367 | Enabled: true 368 | 369 | Style/RedundantConstantBase: 370 | Enabled: true 371 | 372 | Style/RedundantCurrentDirectoryInPath: 373 | Enabled: true 374 | 375 | Style/RedundantDoubleSplatHashBraces: 376 | Enabled: true 377 | 378 | Style/RedundantEach: 379 | Enabled: true 380 | 381 | Style/RedundantFilterChain: 382 | Enabled: true 383 | 384 | Style/RedundantHeredocDelimiterQuotes: 385 | Enabled: true 386 | 387 | Style/RedundantInitialize: 388 | Enabled: true 389 | 390 | Style/RedundantInterpolationUnfreeze: 391 | Enabled: true 392 | 393 | Style/RedundantLineContinuation: 394 | Enabled: true 395 | 396 | Style/RedundantRegexpArgument: 397 | Enabled: true 398 | 399 | Style/RedundantRegexpConstructor: 400 | Enabled: true 401 | 402 | Style/RedundantSelfAssignmentBranch: 403 | Enabled: true 404 | 405 | Style/RedundantStringEscape: 406 | Enabled: true 407 | 408 | Style/ReturnNilInPredicateMethodDefinition: 409 | Enabled: true 410 | 411 | Style/SafeNavigationChainLength: 412 | Enabled: true 413 | 414 | Style/SelectByRegexp: 415 | Enabled: true 416 | 417 | Style/SendWithLiteralMethodName: 418 | Enabled: true 419 | 420 | Style/SingleLineDoEndBlock: 421 | Enabled: true 422 | 423 | Style/StringChars: 424 | Enabled: true 425 | 426 | Style/SuperArguments: 427 | Enabled: true 428 | 429 | Style/SuperWithArgsParentheses: 430 | Enabled: true 431 | 432 | Style/SwapValues: 433 | Enabled: true 434 | 435 | Style/YAMLFileRead: 436 | Enabled: true -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.3 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.0] - 2024-XX-XX 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem 'minitest', '~> 5.25.1' 7 | gem 'minitest-reporters', '~> 1.7.1' 8 | gem 'pry-byebug', '~> 3.10' 9 | gem 'rake', '~> 13.2.1' 10 | gem 'rspec', '~> 3.13.0' 11 | gem 'rubocop', '~> 1.68.0' 12 | gem 'simplecov', '~> 0.22.0' 13 | end 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Emerge Tools 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emerge CLI 2 | 3 | The official CLI for Emerge Tools. 4 | 5 | [Emerge](https://emergetools.com) offers a suite of products to help optimize app size, performance, and quality by detecting regressions before they make it to production. This plugin provides a set of actions to interact with the Emerge API. 6 | 7 | # Documentation 8 | 9 | See [Emerge CLI](https://docs.emergetools.com/docs/emerge-cli#/) for our full documentation. 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | require 'rubocop/rake_task' 4 | 5 | RuboCop::RakeTask.new 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << 'test' 9 | t.libs << 'lib' 10 | t.test_files = Dir.glob('test/**/*_test.rb') 11 | end 12 | 13 | task default: %i[rubocop test] 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'emerge_cli' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. For example: 9 | # require "pry" 10 | # Pry.start 11 | 12 | require 'irb' 13 | IRB.start(__FILE__) 14 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to set up or update your development environment automatically. 15 | puts '== Installing dependencies ==' 16 | system! 'gem install bundler --conservative' 17 | system('bundle check') || system!('bundle install') 18 | end 19 | -------------------------------------------------------------------------------- /emerge_cli.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = 'emerge' 5 | spec.version = EmergeCLI::VERSION 6 | spec.authors = ['Emerge Tools'] 7 | spec.email = ['support@emergetools.com'] 8 | 9 | spec.summary = 'Emerge CLI' 10 | spec.description = 'The official CLI for Emerge Tools' 11 | spec.homepage = 'https://github.com/EmergeTools/emerge-cli' 12 | spec.license = 'MIT' 13 | spec.required_ruby_version = '>= 3.2.0' 14 | 15 | spec.metadata['homepage_uri'] = spec.homepage 16 | spec.metadata['source_code_uri'] = spec.homepage 17 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 18 | 19 | # Specify which files should be included in the gem when published 20 | spec.files = Dir[ 21 | 'lib/**/*', 22 | 'parsers/**/*', 23 | 'LICENSE.txt', 24 | 'README.md', 25 | 'CHANGELOG.md' 26 | ] 27 | 28 | spec.bindir = 'exe' 29 | spec.executables = ['emerge'] 30 | spec.require_paths = ['lib'] 31 | 32 | spec.add_dependency 'async-http', '~> 0.86.0' 33 | spec.add_dependency 'base64', '~> 0.2.0' 34 | spec.add_dependency 'CFPropertyList', '~> 2.3', '>= 2.3.2' 35 | spec.add_dependency 'chunky_png', '~> 1.4.0' 36 | spec.add_dependency 'dry-cli', '~> 1.2.0' 37 | spec.add_dependency 'nkf', '~> 0.1.3' 38 | spec.add_dependency 'open3', '~> 0.2.1' 39 | spec.add_dependency 'ruby-macho', '~> 4.1.0' 40 | spec.add_dependency 'ruby_tree_sitter', '~> 1.9' 41 | spec.add_dependency 'rubyzip', '~> 2.3.0' 42 | spec.add_dependency 'tty-prompt', '~> 0.23.1' 43 | spec.add_dependency 'tty-table', '~> 0.12.0' 44 | spec.add_dependency 'xcodeproj', '~> 1.27.0' 45 | 46 | spec.metadata['rubygems_mfa_required'] = 'true' 47 | end 48 | -------------------------------------------------------------------------------- /exe/emerge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../lib/emerge_cli' 5 | 6 | Dry::CLI.new(EmergeCLI).call 7 | -------------------------------------------------------------------------------- /lib/commands/build/distribution/install.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cfpropertylist' 3 | require 'zip' 4 | require 'rbconfig' 5 | require 'tmpdir' 6 | require 'tty-prompt' 7 | 8 | module EmergeCLI 9 | module Commands 10 | module Build 11 | module Distribution 12 | class Install < EmergeCLI::Commands::GlobalOptions 13 | desc 'Download and install a build from Build Distribution' 14 | 15 | option :api_token, type: :string, required: false, 16 | desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]' 17 | option :id, type: :string, required: true, desc: 'Emerge build ID to download' 18 | option :install, type: :boolean, default: true, required: false, desc: 'Install the build on the device' 19 | option :device_id, type: :string, desc: 'Specific device ID to target' 20 | option :device_type, type: :string, enum: %w[virtual physical any], default: 'any', 21 | desc: 'Type of device to target (virtual/physical/any)' 22 | option :output, type: :string, required: false, desc: 'Output path for the downloaded build' 23 | 24 | def initialize(network: nil) 25 | @network = network 26 | end 27 | 28 | def call(**options) 29 | @options = options 30 | before(options) 31 | 32 | Sync do 33 | api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil) 34 | raise 'API token is required' unless api_token 35 | 36 | raise 'Build ID is required' unless @options[:id] 37 | 38 | output_name = nil 39 | app_id = nil 40 | 41 | begin 42 | @network ||= EmergeCLI::Network.new(api_token:) 43 | 44 | Logger.info 'Getting build URL...' 45 | request = get_build_url(@options[:id]) 46 | response = parse_response(request) 47 | 48 | platform = response['platform'] 49 | download_url = response['downloadUrl'] 50 | app_id = response['appId'] 51 | 52 | extension = platform == 'ios' ? 'ipa' : 'apk' 53 | output_name = @options[:output] || "#{@options[:id]}.#{extension}" 54 | 55 | if File.exist?(output_name) 56 | Logger.info "Build file already exists at #{output_name}" 57 | prompt = TTY::Prompt.new 58 | choice = prompt.select('What would you like to do?', { 59 | 'Install existing file' => :install, 60 | 'Overwrite with new download' => :overwrite, 61 | 'Cancel' => :cancel 62 | }) 63 | 64 | case choice 65 | when :install 66 | Logger.info 'Proceeding with existing file...' 67 | when :overwrite 68 | Logger.info 'Downloading new build...' 69 | `curl --progress-bar -L '#{download_url}' -o #{output_name}` 70 | Logger.info "✅ Build downloaded to #{output_name}" 71 | when :cancel 72 | raise 'Operation cancelled by user' 73 | end 74 | else 75 | Logger.info 'Downloading build...' 76 | `curl --progress-bar -L '#{download_url}' -o #{output_name}` 77 | Logger.info "✅ Build downloaded to #{output_name}" 78 | end 79 | rescue StandardError => e 80 | Logger.error "❌ Failed to download build: #{e.message}" 81 | raise e 82 | ensure 83 | @network&.close 84 | end 85 | 86 | begin 87 | if @options[:install] && !output_name.nil? 88 | if platform == 'ios' 89 | install_ios_build(output_name, app_id) 90 | elsif platform == 'android' 91 | install_android_build(output_name) 92 | end 93 | end 94 | rescue StandardError => e 95 | Logger.error "❌ Failed to install build: #{e.message}" 96 | raise e 97 | end 98 | end 99 | end 100 | 101 | private 102 | 103 | def get_build_url(build_id) 104 | @network.get( 105 | path: '/distribution/downloadUrl', 106 | max_retries: 3, 107 | query: { 108 | buildId: build_id 109 | } 110 | ) 111 | end 112 | 113 | def parse_response(response) 114 | case response.status 115 | when 200 116 | JSON.parse(response.read) 117 | when 400 118 | error_message = JSON.parse(response.read)['errorMessage'] 119 | raise "Invalid parameters: #{error_message}" 120 | when 401, 403 121 | raise 'Invalid API token' 122 | else 123 | raise "Getting build failed with status #{response.status}" 124 | end 125 | end 126 | 127 | def install_ios_build(build_path, app_id) 128 | device_type = case @options[:device_type] 129 | when 'simulator' 130 | XcodeDeviceManager::DeviceType::VIRTUAL 131 | when 'physical' 132 | XcodeDeviceManager::DeviceType::PHYSICAL 133 | else 134 | XcodeDeviceManager::DeviceType::ANY 135 | end 136 | 137 | device_manager = XcodeDeviceManager.new 138 | device = if @options[:device_id] 139 | device_manager.find_device_by_id(@options[:device_id]) 140 | else 141 | device_manager.find_device_by_type(device_type, build_path) 142 | end 143 | 144 | Logger.info "Installing build on #{device.device_id}" 145 | device.install_app(build_path) 146 | Logger.info '✅ Build installed' 147 | 148 | Logger.info "Launching app #{app_id}..." 149 | device.launch_app(app_id) 150 | Logger.info '✅ Build launched' 151 | end 152 | 153 | def install_android_build(build_path) 154 | device_id = @options[:device_id] || select_android_device 155 | raise 'No Android devices found' unless device_id 156 | 157 | command = "adb -s #{device_id} install #{build_path}" 158 | Logger.debug "Running command: #{command}" 159 | `#{command}` 160 | 161 | Logger.info '✅ Build installed' 162 | end 163 | 164 | def select_android_device 165 | devices = get_android_devices 166 | return nil if devices.empty? 167 | return devices.first if devices.length == 1 168 | 169 | prompt = TTY::Prompt.new 170 | Logger.info 'Multiple Android devices found.' 171 | prompt.select('Choose a device:', devices) 172 | end 173 | 174 | def get_android_devices 175 | output = `adb devices` 176 | # Split output into lines, remove first line (header), and extract device IDs 177 | output.split("\n")[1..] 178 | .map(&:strip) 179 | .reject(&:empty?) 180 | .map { |line| line.split("\t").first } 181 | end 182 | end 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/commands/build/distribution/validate.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cfpropertylist' 3 | require 'zip' 4 | require 'rbconfig' 5 | 6 | module EmergeCLI 7 | module Commands 8 | module Build 9 | module Distribution 10 | class ValidateApp < EmergeCLI::Commands::GlobalOptions 11 | desc 'Validate app for build distribution' 12 | 13 | option :path, type: :string, required: true, desc: 'Path to the xcarchive, IPA or APK to validate' 14 | 15 | # Constants 16 | PLIST_START = ' Time.now 129 | Logger.info '✅ Provisioning profile hasn\'t expired' 130 | else 131 | Logger.info "❌ Provisioning profile is expired. Expiration date: #{expiration_date}" 132 | end 133 | 134 | provisions_all_devices = parsed_data['ProvisionsAllDevices'] 135 | if provisions_all_devices 136 | Logger.info 'Provisioning profile supports all devices (likely an enterprise profile)' 137 | else 138 | devices = parsed_data['ProvisionedDevices'] 139 | Logger.info 'Provisioning profile does not support all devices (likely a development profile).' 140 | Logger.info "Devices: #{devices.inspect}" 141 | end 142 | end 143 | 144 | def check_supported_abis(apk_path) 145 | abis = [] 146 | 147 | Zip::File.open(apk_path) do |zip_file| 148 | zip_file.each do |entry| 149 | if entry.name.start_with?('lib/') && entry.name.count('/') == 2 150 | abi = entry.name.split('/')[1] 151 | abis << abi unless abis.include?(abi) 152 | end 153 | end 154 | end 155 | 156 | unless abis.include?(EXPECTED_ABI) 157 | raise "APK does not support #{EXPECTED_ABI} architecture, found: #{abis.join(', ')}" 158 | end 159 | 160 | Logger.info "✅ APK supports #{EXPECTED_ABI} architecture" 161 | end 162 | end 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/commands/config/orderfiles/orderfiles_ios.rb: -------------------------------------------------------------------------------- 1 | require 'xcodeproj' 2 | 3 | module EmergeCLI 4 | module Commands 5 | module Config 6 | class OrderFilesIOS < EmergeCLI::Commands::GlobalOptions 7 | desc 'Configure order files for iOS' 8 | 9 | # Optional options 10 | option :skip_download_script, type: :boolean, required: false, desc: 'Only enable linkmaps' 11 | option :project_path, type: :string, required: false, 12 | desc: 'Path to the xcode project (will use first found if not provided)' 13 | 14 | # Constants 15 | LINK_MAPS_CONFIG = 'LD_GENERATE_MAP_FILE'.freeze 16 | LINK_MAPS_PATH = 'LD_MAP_FILE_PATH'.freeze 17 | PATH_TO_LINKMAP = '$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt'.freeze 18 | ORDER_FILE = 'ORDER_FILE'.freeze 19 | ORDER_FILE_PATH = '$(PROJECT_DIR)/orderfiles/orderfile.txt'.freeze 20 | 21 | def call(**options) 22 | @options = options 23 | before(options) 24 | 25 | if @options[:project_path] 26 | project = Xcodeproj::Project.open(@options[:project_path]) 27 | else 28 | project = Xcodeproj::Project.open(Dir.glob('*.xcodeproj').first) 29 | Logger.warn 'No project path provided, using first found xcodeproj in current directory' 30 | end 31 | 32 | enable_linkmaps(project) 33 | 34 | add_order_files_download_script(project) unless @options[:skip_download_script] 35 | 36 | project.save 37 | end 38 | 39 | private 40 | 41 | def enable_linkmaps(project) 42 | Logger.info 'Enabling Linkmaps' 43 | project.targets.each do |target| 44 | # Only do it for app targets 45 | next unless target.product_type == 'com.apple.product-type.application' 46 | 47 | Logger.info " Target: #{target.name}" 48 | target.build_configurations.each do |config| 49 | config.build_settings[LINK_MAPS_CONFIG] = 'YES' 50 | config.build_settings[LINK_MAPS_PATH] = PATH_TO_LINKMAP 51 | end 52 | end 53 | end 54 | 55 | def add_order_files_download_script(project) 56 | Logger.info 'Adding order files download script' 57 | project.targets.each do |target| 58 | # Only do it for app targets 59 | next unless target.product_type == 'com.apple.product-type.application' 60 | 61 | Logger.info " Target: #{target.name}" 62 | 63 | # Create the script phase if it doesn't exist 64 | phase = target.shell_script_build_phases.find { |item| item.name == 'EmergeTools Download Order Files' } 65 | if phase.nil? 66 | Logger.info " Creating script 'EmergeTools Download Order Files'" 67 | phase = target.new_shell_script_build_phase('EmergeTools Download Order Files') 68 | phase.shell_script = <<~BASH 69 | if [ "$CONFIGURATION" != "Release" ]; then 70 | echo "Skipping script for non-Release build" 71 | exit 0 72 | fi 73 | 74 | if curl --fail "https://order-files-prod.emergetools.com/$PRODUCT_BUNDLE_IDENTIFIER/$MARKETING_VERSION" \ 75 | -H "X-API-Token: $EMERGE_API_TOKEN" -o ORDER_FILE.gz ; then 76 | mkdir -p "$PROJECT_DIR/orderfiles" 77 | gunzip -c ORDER_FILE.gz > $PROJECT_DIR/orderfiles/orderfile.txt 78 | else 79 | echo "cURL request failed. Creating an empty file." 80 | mkdir -p "$PROJECT_DIR/orderfiles" 81 | touch "$PROJECT_DIR/orderfiles/orderfile.txt" 82 | fi; 83 | BASH 84 | phase.output_paths = ['$(PROJECT_DIR)/orderfiles/orderfile.txt'] 85 | else 86 | Logger.info " 'EmergeTools Download Order Files' already exists" 87 | end 88 | # Make sure it is the first build phase 89 | target.build_phases.move(phase, 0) 90 | 91 | target.build_configurations.each do |config| 92 | config.build_settings[ORDER_FILE] = ORDER_FILE_PATH 93 | end 94 | end 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/commands/config/snapshots/snapshots_ios.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'json' 3 | require 'uri' 4 | require 'chunky_png' 5 | require 'async' 6 | require 'async/barrier' 7 | require 'async/semaphore' 8 | require 'async/http/internet/instance' 9 | require 'yaml' 10 | 11 | require 'tty-prompt' 12 | require 'tty-table' 13 | 14 | module EmergeCLI 15 | module Commands 16 | module Config 17 | class SnapshotsIOS < EmergeCLI::Commands::GlobalOptions 18 | desc 'Configure snapshot testing for iOS' 19 | 20 | # Optional options 21 | option :interactive, type: :boolean, required: false, 22 | desc: 'Run interactively' 23 | option :clear, type: :boolean, required: false, desc: 'Clear existing configuration' 24 | option :os_version, type: :string, required: true, desc: 'OS version' 25 | option :launch_arguments, type: :array, required: false, desc: 'Launch arguments to set' 26 | option :env_variables, type: :array, required: false, desc: 'Environment variables to set' 27 | option :exact_match_excluded_previews, type: :array, required: false, desc: 'Exact match excluded previews' 28 | option :regex_excluded_previews, type: :array, required: false, desc: 'Regex excluded previews' 29 | 30 | # Constants 31 | EXCLUDED_PREVIEW_PROMPT = 'Do you want to exclude any previews by exact match?'.freeze 32 | EXCLUDED_PREVIEW_FINISH_PROMPT = 'Enter the previews you want to exclude (leave blank to finish)'.freeze 33 | EXCLUDED_REGEX_PREVIEW_PROMPT = 'Do you want to exclude any previews by regex?'.freeze 34 | EXCLUDED_REGEX_PREVIEW_FINISH_PROMPT = 'Enter the previews you want to exclude (leave blank to finish)'.freeze 35 | ARGUMENTS_PROMPT = 'Do you want to set any arguments?'.freeze 36 | ARGUMENTS_FINISH_PROMPT = 'Enter the argument you want to set (leave blank to finish)'.freeze 37 | ENV_VARIABLES_PROMPT = 'Do you want to set any environment variables?'.freeze 38 | ENV_VARIABLES_FINISH_PROMPT = "Enter the environment variable you want to set (leave blank to finish) with \ 39 | format KEY=VALUE".freeze 40 | AVAILABLE_OS_VERSIONS = ['17.2', '17.5', '18.0'].freeze 41 | 42 | def call(**options) 43 | @options = options 44 | before(options) 45 | 46 | Sync do 47 | validate_options 48 | 49 | run_interactive_mode if @options[:interactive] 50 | 51 | run_non_interactive_mode if !@options[:interactive] 52 | 53 | Logger.warn 'Remember to copy `emerge_config.yml` to your project XCArchive before uploading it!' 54 | end 55 | end 56 | 57 | private 58 | 59 | def validate_options 60 | if @options[:interactive] && (!@options[:os_version].nil? || !@options[:launch_arguments].nil? || 61 | !@options[:env_variables].nil? || !@options[:exact_match_excluded_previews].nil? || 62 | !@options[:regex_excluded_previews].nil?) 63 | Logger.warn 'All options are ignored when using interactive mode' 64 | end 65 | end 66 | 67 | def run_interactive_mode 68 | prompt = TTY::Prompt.new 69 | 70 | override_config = false 71 | if File.exist?('emerge_config.yml') 72 | Logger.warn 'There is already a emerge_config.yml file.' 73 | prompt.yes?('Do you want to overwrite it?', default: false) do |answer| 74 | override_config = true if answer 75 | end 76 | end 77 | 78 | if !override_config && File.exist?('emerge_config.yml') 79 | config = YAML.load_file('emerge_config.yml') 80 | config['snapshots']['ios']['runSettings'] = [] 81 | else 82 | config = { 83 | 'version' => 2.0, 84 | 'snapshots' => { 85 | 'ios' => { 86 | 'runSettings' => [] 87 | } 88 | } 89 | } 90 | end 91 | 92 | Logger.info 'Creating a new config file' 93 | 94 | end_config = false 95 | loop do 96 | os_version = get_os_version(prompt) 97 | 98 | excluded_previews = get_array_from_user(prompt, EXCLUDED_PREVIEW_PROMPT, EXCLUDED_PREVIEW_FINISH_PROMPT) 99 | excluded_regex_previews = get_array_from_user(prompt, EXCLUDED_REGEX_PREVIEW_PROMPT, 100 | EXCLUDED_REGEX_PREVIEW_FINISH_PROMPT) 101 | arguments_array = get_array_from_user(prompt, ARGUMENTS_PROMPT, ARGUMENTS_FINISH_PROMPT) 102 | env_variables_array = get_array_from_user(prompt, ENV_VARIABLES_PROMPT, ENV_VARIABLES_FINISH_PROMPT) 103 | 104 | excluded = get_parsed_previews(excluded_previews, excluded_regex_previews) 105 | env_variables = get_parsed_env_variables(env_variables_array) 106 | 107 | os_settings = { 108 | 'osVersion' => os_version, 109 | 'excludedPreviews' => excluded, 110 | 'envVariables' => env_variables, 111 | 'arguments' => arguments_array 112 | } 113 | show_config(os_settings) 114 | save = prompt.yes?('Do you want to save this setting?') 115 | config['snapshots']['ios']['runSettings'].push(os_settings) if save 116 | 117 | end_config = !prompt.yes?('Do you want to continue adding more settings?') 118 | break if end_config 119 | end 120 | 121 | File.write('emerge_config.yml', config.to_yaml) 122 | Logger.info 'Configuration file created successfully!' 123 | end 124 | 125 | def run_non_interactive_mode 126 | config = {} 127 | if File.exist?('emerge_config.yml') 128 | config = YAML.load_file('emerge_config.yml') 129 | if !@options[:clear] && !config['snapshots'].nil? && !config['snapshots']['ios'].nil? && 130 | !config['snapshots']['ios']['runSettings'].nil? 131 | raise 'There is already a configuration file with settings. Use the --clear flag to overwrite it.' 132 | end 133 | 134 | config['snapshots']['ios']['runSettings'] = [] 135 | 136 | else 137 | config = { 138 | 'version' => 2.0, 139 | 'snapshots' => { 140 | 'ios' => { 141 | 'runSettings' => [] 142 | } 143 | } 144 | } 145 | end 146 | 147 | excluded_previews = get_parsed_previews(@options[:exact_match_excluded_previews] || [], 148 | @options[:regex_excluded_previews] || []) 149 | env_variables = get_parsed_env_variables(@options[:env_variables] || []) 150 | 151 | os_version = @options[:os_version] 152 | if os_version.nil? 153 | Logger.warn 'No OS version was provided, defaulting to 17.5' 154 | os_version = '17.5' 155 | end 156 | 157 | os_settings = { 158 | 'osVersion' => os_version, 159 | 'excludedPreviews' => excluded_previews, 160 | 'envVariables' => env_variables, 161 | 'arguments' => @options[:launch_arguments] || [] 162 | } 163 | config['snapshots']['ios']['runSettings'].push(os_settings) 164 | File.write('emerge_config.yml', config.to_yaml) 165 | Logger.info 'Configuration file created successfully!' 166 | show_config(os_settings) 167 | end 168 | 169 | def get_os_version(prompt) 170 | os_version = prompt.select('Select the OS version you want to run the tests on') do |answer| 171 | AVAILABLE_OS_VERSIONS.each do |version| 172 | answer.choice version, version.to_f 173 | end 174 | answer.choice 'Custom', 'custom' 175 | end 176 | os_version = prompt.ask('Enter the OS version you want to run the tests on') if os_version == 'custom' 177 | os_version 178 | end 179 | 180 | def get_array_from_user(prompt, first_prompt_message, second_prompt_message) 181 | continue = prompt.yes?(first_prompt_message) 182 | return [] if !continue 183 | array = [] 184 | loop do 185 | item = prompt.ask(second_prompt_message) 186 | if item == '' || item.nil? 187 | continue = false 188 | else 189 | array.push(item) 190 | end 191 | break unless continue 192 | end 193 | array 194 | end 195 | 196 | def show_config(config) 197 | table = TTY::Table.new( 198 | header: %w[Key Value], 199 | rows: config.to_a 200 | ) 201 | puts table.render(:ascii) 202 | end 203 | 204 | def get_parsed_previews(previews_exact, previews_regex) 205 | excluded = previews_exact.map do |preview| 206 | { 207 | 'type' => 'exact', 208 | 'value' => preview 209 | } 210 | end 211 | previews_regex.each do |preview| 212 | excluded.push({ 213 | 'type' => 'regex', 214 | 'value' => preview 215 | }) 216 | end 217 | excluded 218 | end 219 | 220 | def get_parsed_env_variables(env_variables) 221 | env_variables_array_fixed = [] 222 | env_variables.each do |env_variable| 223 | key, value = env_variable.split('=') 224 | env_variables_array_fixed.push({ 225 | 'key' => key, 'value' => value 226 | }) 227 | end 228 | end 229 | end 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/commands/fix/exported_symbols.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'xcodeproj' 3 | 4 | module EmergeCLI 5 | module Commands 6 | module Fix 7 | class ExportedSymbols < EmergeCLI::Commands::GlobalOptions 8 | desc 'Remove exported symbols from built binaries' 9 | 10 | option :path, type: :string, required: true, desc: 'Path to the xcarchive' 11 | 12 | # Constants 13 | DEFAULT_EXPORTED_SYMBOLS = %(_main 14 | __mh_execute_header).freeze 15 | EXPORTED_SYMBOLS_FILE = 'EXPORTED_SYMBOLS_FILE'.freeze 16 | EXPORTED_SYMBOLS_PATH = '$(SRCROOT)/EmergeToolsHelperFiles/ExportedSymbols'.freeze 17 | EXPORTED_SYMBOLS_FILE_NAME = 'ExportedSymbols'.freeze 18 | EMERGE_TOOLS_GROUP = 'EmergeToolsHelperFiles'.freeze 19 | 20 | def call(**options) 21 | @options = options 22 | before(options) 23 | 24 | raise 'Path must be an xcodeproj' unless @options[:path].end_with?('.xcodeproj') 25 | raise 'Path does not exist' unless File.exist?(@options[:path]) 26 | 27 | Sync do 28 | project = Xcodeproj::Project.open(@options[:path]) 29 | 30 | # Add the exported symbols file to the project 31 | group = project.main_group 32 | emergetools_group = group.find_subpath(EMERGE_TOOLS_GROUP, true) 33 | emergetools_group.set_path(EMERGE_TOOLS_GROUP) 34 | 35 | unless emergetools_group.find_file_by_path(EXPORTED_SYMBOLS_FILE_NAME) 36 | emergetools_group.new_file(EXPORTED_SYMBOLS_FILE_NAME) 37 | end 38 | 39 | # Create Folder if it doesn't exist 40 | 41 | FileUtils.mkdir_p(File.join(File.dirname(@options[:path]), EMERGE_TOOLS_GROUP)) 42 | 43 | # Create the exported symbols file 44 | path = File.join(File.dirname(@options[:path]), EMERGE_TOOLS_GROUP, EXPORTED_SYMBOLS_FILE_NAME) 45 | File.write(path, DEFAULT_EXPORTED_SYMBOLS) 46 | 47 | project.targets.each do |target| 48 | # Only do it for app targets 49 | next unless target.product_type == 'com.apple.product-type.application' 50 | 51 | target.build_configurations.each do |config| 52 | config.build_settings[EXPORTED_SYMBOLS_FILE] = EXPORTED_SYMBOLS_PATH 53 | end 54 | end 55 | 56 | project.save 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/commands/fix/minify_strings.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'xcodeproj' 3 | 4 | module EmergeCLI 5 | module Commands 6 | module Fix 7 | class MinifyStrings < EmergeCLI::Commands::GlobalOptions 8 | desc 'Minify strings in the app' 9 | 10 | option :path, type: :string, required: true, desc: 'Path to the xcarchive' 11 | 12 | # Constants 13 | SCRIPT_NAME = 'EmergeTools Minify Strings'.freeze 14 | ENABLE_USER_SCRIPT_SANDBOXING = 'ENABLE_USER_SCRIPT_SANDBOXING'.freeze 15 | STRINGS_FILE_OUTPUT_ENCODING = 'STRINGS_FILE_OUTPUT_ENCODING'.freeze 16 | STRINGS_FILE_OUTPUT_ENCODING_VALUE = 'UTF-8'.freeze 17 | SCRIPT_CONTENT = %{import os 18 | import json 19 | from multiprocessing.pool import ThreadPool 20 | 21 | def minify(file_path): 22 | os.system(f"plutil -convert json '{file_path}'") 23 | new_content = '' 24 | try: 25 | with open(file_path, 'r') as input_file: 26 | data = json.load(input_file) 27 | 28 | for key, value in data.items(): 29 | fixed_key = json.dumps(key, ensure_ascii=False).encode('utf8').decode() 30 | fixed_value = json.dumps(value, ensure_ascii=False).encode('utf8').decode() 31 | new_line = f'{fixed_key} = {fixed_value};\\n' 32 | new_content += new_line 33 | 34 | with open(file_path, 'w') as output_file: 35 | output_file.write(new_content) 36 | except: 37 | return 38 | 39 | file_extension = '.strings' 40 | stringFiles = [] 41 | 42 | for root, _, files in os.walk(os.environ['BUILT_PRODUCTS_DIR'], followlinks=True): 43 | for filename in files: 44 | if filename.endswith(file_extension): 45 | input_path = os.path.join(root, filename) 46 | stringFiles.append(input_path) 47 | 48 | # create a thread pool 49 | with ThreadPool() as pool: 50 | pool.map(minify, stringFiles) 51 | }.freeze 52 | 53 | def call(**options) 54 | @options = options 55 | before(options) 56 | 57 | raise 'Path must be an xcodeproj' unless @options[:path].end_with?('.xcodeproj') 58 | raise 'Path does not exist' unless File.exist?(@options[:path]) 59 | 60 | Sync do 61 | project = Xcodeproj::Project.open(@options[:path]) 62 | 63 | project.targets.each do |target| 64 | target.build_configurations.each do |config| 65 | enable_user_script_sandboxing(config) 66 | set_output_encoding(config) 67 | end 68 | 69 | add_run_script(target) 70 | end 71 | 72 | project.save 73 | end 74 | end 75 | 76 | private 77 | 78 | def enable_user_script_sandboxing(config) 79 | Logger.info "Enabling user script sandboxing for #{config.name}" 80 | config.build_settings[ENABLE_USER_SCRIPT_SANDBOXING] = 'NO' 81 | end 82 | 83 | def set_output_encoding(config) 84 | Logger.info "Setting output encoding for #{config.name}" 85 | config.build_settings[STRINGS_FILE_OUTPUT_ENCODING] = STRINGS_FILE_OUTPUT_ENCODING_VALUE 86 | end 87 | 88 | def add_run_script(target) 89 | phase = target.shell_script_build_phases.find { |item| item.name == SCRIPT_NAME } 90 | return unless phase.nil? 91 | Logger.info "Creating script '#{SCRIPT_NAME}'" 92 | phase = target.new_shell_script_build_phase(SCRIPT_NAME) 93 | phase.shell_script = SCRIPT_CONTENT 94 | phase.shell_path = `which python3`.strip 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/commands/fix/strip_binary_symbols.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'xcodeproj' 3 | 4 | module EmergeCLI 5 | module Commands 6 | module Fix 7 | class StripBinarySymbols < EmergeCLI::Commands::GlobalOptions 8 | desc 'Strip binary symbols from the app' 9 | 10 | option :path, type: :string, required: true, desc: 'Path to the xcarchive' 11 | 12 | # Constants 13 | SCRIPT_NAME = 'EmergeTools Strip Binary Symbols'.freeze 14 | ENABLE_USER_SCRIPT_SANDBOXING = 'ENABLE_USER_SCRIPT_SANDBOXING'.freeze 15 | INPUT_FILE = '${DWARF_DSYM_FOLDER_PATH}/${EXECUTABLE_NAME}.app.dSYM/' \ 16 | 'Contents/Resources/DWARF/${EXECUTABLE_NAME}'.freeze 17 | SCRIPT_CONTENT = %{#!/bin/bash 18 | set -e 19 | 20 | echo "Starting the symbol stripping process..." 21 | 22 | if [ "Release" = "$\{CONFIGURATION\}" ]; then 23 | echo "Configuration is Release." 24 | 25 | # Path to the app directory 26 | APP_DIR_PATH="$\{BUILT_PRODUCTS_DIR\}/$\{EXECUTABLE_FOLDER_PATH\}" 27 | echo "App directory path: $\{APP_DIR_PATH\}" 28 | 29 | # Strip main binary 30 | echo "Stripping main binary: $\{APP_DIR_PATH\}/$\{EXECUTABLE_NAME\}" 31 | strip -rSTx "$\{APP_DIR_PATH\}/$\{EXECUTABLE_NAME\}" 32 | if [ $? -eq 0 ]; then 33 | echo "Successfully stripped main binary." 34 | else 35 | echo "Failed to strip main binary." >&2 36 | fi 37 | 38 | # Path to the Frameworks directory 39 | APP_FRAMEWORKS_DIR="$\{APP_DIR_PATH\}/Frameworks" 40 | echo "Frameworks directory path: $\{APP_FRAMEWORKS_DIR\}" 41 | 42 | # Strip symbols from frameworks, if Frameworks/ exists at all 43 | # ... as long as the framework is NOT signed by Apple 44 | if [ -d "$\{APP_FRAMEWORKS_DIR\}" ]; then 45 | echo "Frameworks directory exists. Proceeding to strip symbols from frameworks." 46 | find "$\{APP_FRAMEWORKS_DIR\}" -type f -perm +111 -maxdepth 2 -mindepth 2 -exec bash -c ' 47 | codesign -v -R="anchor apple" "\{\}" &> /dev/null || 48 | ( 49 | echo "Stripping \{\}" && 50 | if [ -w "\{\}" ]; then 51 | strip -rSTx "\{\}" 52 | if [ $? -eq 0 ]; then 53 | echo "Successfully stripped \{\}" 54 | else 55 | echo "Failed to strip \{\}" >&2 56 | fi 57 | else 58 | echo "Warning: No write permission for \{\}" 59 | fi 60 | ) 61 | ' \\; 62 | if [ $? -eq 0 ]; then 63 | echo "Successfully stripped symbols from frameworks." 64 | else 65 | echo "Failed to strip symbols from some frameworks." >&2 66 | fi 67 | else 68 | echo "Frameworks directory does not exist. Skipping framework stripping." 69 | fi 70 | else 71 | echo "Configuration is not Release. Skipping symbol stripping." 72 | fi 73 | 74 | echo "Symbol stripping process completed."}.freeze 75 | 76 | def call(**options) 77 | @options = options 78 | before(options) 79 | 80 | raise 'Path must be an xcodeproj' unless @options[:path].end_with?('.xcodeproj') 81 | raise 'Path does not exist' unless File.exist?(@options[:path]) 82 | 83 | Sync do 84 | project = Xcodeproj::Project.open(@options[:path]) 85 | 86 | project.targets.each do |target| 87 | target.build_configurations.each do |config| 88 | enable_user_script_sandboxing(config) 89 | end 90 | 91 | add_run_script(target) 92 | end 93 | 94 | project.save 95 | end 96 | end 97 | 98 | private 99 | 100 | def enable_user_script_sandboxing(config) 101 | Logger.info "Enabling user script sandboxing for #{config.name}" 102 | config.build_settings[ENABLE_USER_SCRIPT_SANDBOXING] = 'NO' 103 | end 104 | 105 | def add_run_script(target) 106 | phase = target.shell_script_build_phases.find { |item| item.name == SCRIPT_NAME } 107 | return unless phase.nil? 108 | Logger.info "Creating script '#{SCRIPT_NAME}'" 109 | phase = target.new_shell_script_build_phase(SCRIPT_NAME) 110 | phase.shell_script = SCRIPT_CONTENT 111 | phase.input_paths = [INPUT_FILE] 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/commands/global_options.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'logger' 3 | 4 | module EmergeCLI 5 | module Commands 6 | class GlobalOptions < Dry::CLI::Command 7 | option :debug, type: :boolean, default: false, desc: 'Enable debug logging' 8 | 9 | def before(args) 10 | log_level = args[:debug] ? ::Logger::DEBUG : ::Logger::INFO 11 | EmergeCLI::Logger.configure(log_level) 12 | 13 | EmergeCLI::Utils::VersionCheck.new.check_version 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/commands/integrate/fastlane.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'fileutils' 3 | 4 | module EmergeCLI 5 | module Commands 6 | module Integrate 7 | class Fastlane < EmergeCLI::Commands::GlobalOptions 8 | desc 'Integrate Emerge into your iOS project via Fastlane' 9 | 10 | argument :path, type: :string, required: false, default: '.', 11 | desc: 'Project path (defaults to current directory)' 12 | 13 | def call(path: '.', **_options) 14 | @project_path = File.expand_path(path) 15 | Logger.info "Project path: #{@project_path}" 16 | 17 | Logger.info '🔍 Detecting project type...' 18 | detector = ProjectDetector.new(@project_path) 19 | 20 | if detector.ios_project? 21 | Logger.info '📱 iOS project detected!' 22 | setup_ios 23 | else 24 | Logger.error "❌ Error: Could not detect project. Make sure you're in the root directory of an iOS project." 25 | exit 1 26 | end 27 | end 28 | 29 | private 30 | 31 | def setup_ios 32 | Logger.info 'Setting up Emerge Tools for iOS project using Fastlane...' 33 | 34 | setup_gemfile 35 | setup_fastfile 36 | 37 | # Install Emerge Fastlane plugin 38 | Logger.info 'Installing Emerge Fastlane plugin...' 39 | system('fastlane add_plugin emerge') 40 | 41 | print_ios_completion_message 42 | end 43 | 44 | def setup_gemfile 45 | gemfile_path = File.join(@project_path, 'Gemfile') 46 | if File.exist?(gemfile_path) 47 | Logger.info 'Updating existing Gemfile...' 48 | current_content = File.read(gemfile_path) 49 | current_content << "\ngem 'fastlane'" unless current_content.include?('gem "fastlane"') 50 | current_content << "\ngem 'xcpretty'" unless current_content.include?('gem "xcpretty"') 51 | File.write(gemfile_path, current_content) 52 | else 53 | Logger.error 'No Gemfile found. Please follow the Fastlane setup instructions before running this.' 54 | exit 1 55 | end 56 | 57 | Logger.info 'Installing gems...' 58 | system('bundle install') 59 | end 60 | 61 | def setup_fastfile 62 | fastfile_dir = File.join(@project_path, 'fastlane') 63 | FileUtils.mkdir_p(fastfile_dir) 64 | fastfile_path = File.join(fastfile_dir, 'Fastfile') 65 | 66 | if File.exist?(fastfile_path) 67 | Logger.info 'Updating existing Fastfile...' 68 | update_existing_fastfile(fastfile_path) 69 | else 70 | Logger.error 'No Fastfile found. Please follow the Fastlane setup instructions before running this.' 71 | exit 1 72 | end 73 | end 74 | 75 | def update_existing_fastfile(fastfile_path) 76 | current_content = File.read(fastfile_path) 77 | 78 | # Add platform :ios block if not present 79 | current_content += "\nplatform :ios do\nend\n" unless current_content.match?(/platform\s+:ios\s+do/) 80 | 81 | # Add app_size lane if not present 82 | unless current_content.match?(/^\s*lane\s*:app_size\s*do/) 83 | app_size_lane = <<~RUBY.gsub(/^/, ' ') 84 | lane :app_size do 85 | # NOTE: If you already have a lane setup to build your app, then you can that instead of this and call emerge() after it. 86 | build_app(scheme: ENV["SCHEME_NAME"], export_method: "development") 87 | emerge(tag: ENV['EMERGE_BUILD_TYPE'] || "default") 88 | end 89 | RUBY 90 | current_content.sub!(/platform\s+:ios\s+do.*$/) { |match| "#{match}\n#{app_size_lane}" } 91 | end 92 | 93 | # Add snapshots lane if not present 94 | unless current_content.match?(/^\s*lane\s*:build_upload_emerge_snapshot\s*do/) 95 | snapshot_lane = <<~RUBY.gsub(/^/, ' ') 96 | desc 'Build and upload snapshot build to Emerge Tools' 97 | lane :build_upload_emerge_snapshot do 98 | emerge_snapshot(scheme: ENV["SCHEME_NAME"]) 99 | end 100 | RUBY 101 | current_content.sub!(/lane\s+:app_size\s+do.*?end/m) { |match| "#{match}\n\n#{snapshot_lane}" } 102 | end 103 | 104 | # Clean up any multiple blank lines 105 | current_content.gsub!(/\n{3,}/, "\n\n") 106 | 107 | File.write(fastfile_path, current_content) 108 | end 109 | 110 | def command_exists?(command) 111 | system("which #{command} > /dev/null 2>&1") 112 | end 113 | 114 | def print_ios_completion_message 115 | Logger.info "✅ iOS setup complete! Don't forget to:" 116 | Logger.info '1. Set your EMERGE_API_TOKEN environment variable (both locally and in your CI/CD pipeline)' 117 | Logger.info '2. Set your SCHEME_NAME environment variable' 118 | Logger.info "3. Run 'fastlane app_size' to analyze your app" 119 | Logger.info "4. Run 'fastlane build_upload_emerge_snapshot' to analyze your snapshots" 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/commands/order_files/download_order_files.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | 3 | module EmergeCLI 4 | module Commands 5 | module OrderFiles 6 | class Download < EmergeCLI::Commands::GlobalOptions 7 | desc 'Download order files from Emerge' 8 | 9 | option :bundle_id, type: :string, required: true, desc: 'Bundle identifier to download order files for' 10 | 11 | option :api_token, type: :string, required: false, 12 | desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]' 13 | 14 | option :app_version, type: :string, required: true, 15 | desc: 'App version to download order files for' 16 | 17 | option :unzip, type: :boolean, required: false, 18 | desc: 'Unzip the order file after downloading' 19 | 20 | option :output, type: :string, required: false, 21 | desc: 'Output name for the order file, defaults to bundle_id-app_version.gz' 22 | 23 | EMERGE_ORDER_FILE_URL = 'order-files-prod.emergetools.com'.freeze 24 | 25 | def initialize(network: nil) 26 | @network = network 27 | end 28 | 29 | def call(**options) 30 | @options = options 31 | before(options) 32 | 33 | begin 34 | api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil) 35 | raise 'API token is required' unless api_token 36 | 37 | raise 'Bundle ID is required' unless @options[:bundle_id] 38 | raise 'App version is required' unless @options[:app_version] 39 | 40 | @network ||= EmergeCLI::Network.new(api_token:, base_url: EMERGE_ORDER_FILE_URL) 41 | output_name = @options[:output] || "#{@options[:bundle_id]}-#{@options[:app_version]}.gz" 42 | output_name = "#{output_name}.gz" unless output_name.end_with?('.gz') 43 | 44 | Sync do 45 | request = get_order_file(options[:bundle_id], options[:app_version]) 46 | response = request.read 47 | 48 | File.write(output_name, response) 49 | 50 | if @options[:unzip] 51 | Logger.info 'Unzipping order file...' 52 | Zlib::GzipReader.open(output_name) do |gz| 53 | File.write(output_name.gsub('.gz', ''), gz.read) 54 | end 55 | end 56 | 57 | Logger.info 'Order file downloaded successfully' 58 | end 59 | rescue StandardError => e 60 | Logger.error "Failed to download order file: #{e.message}" 61 | Logger.error 'Check your parameters and try again' 62 | raise e 63 | ensure 64 | @network&.close 65 | end 66 | end 67 | 68 | private 69 | 70 | def get_order_file(bundle_id, app_version) 71 | @network.get( 72 | path: "/#{bundle_id}/#{app_version}", 73 | max_retries: 0 74 | ) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/commands/order_files/validate_linkmaps.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cfpropertylist' 3 | 4 | module EmergeCLI 5 | module Commands 6 | module OrderFiles 7 | class ValidateLinkmaps < EmergeCLI::Commands::GlobalOptions 8 | desc 'Validate linkmaps in xcarchive' 9 | 10 | option :path, type: :string, required: true, desc: 'Path to the xcarchive to validate' 11 | 12 | def initialize(network: nil) 13 | @network = network 14 | end 15 | 16 | def call(**options) 17 | @options = options 18 | before(options) 19 | 20 | Sync do 21 | executable_name = get_executable_name 22 | raise 'Executable not found' if executable_name.nil? 23 | 24 | Logger.info "Using executable: #{executable_name}" 25 | 26 | linkmaps_path = File.join(@options[:path], 'Linkmaps') 27 | raise 'Linkmaps folder not found' unless File.directory?(linkmaps_path) 28 | 29 | linkmaps = Dir.glob("#{linkmaps_path}/*.txt") 30 | raise 'No linkmaps found' if linkmaps.empty? 31 | 32 | executable_linkmaps = linkmaps.select do |linkmap| 33 | File.basename(linkmap).start_with?(executable_name) 34 | end 35 | raise 'No linkmaps found for executable' if executable_linkmaps.empty? 36 | 37 | Logger.info "✅ Found linkmaps for #{executable_name}" 38 | end 39 | end 40 | 41 | private 42 | 43 | def get_executable_name 44 | raise 'Path must be an xcarchive' unless @options[:path].end_with?('.xcarchive') 45 | 46 | app_path = Dir.glob("#{@options[:path]}/Products/Applications/*.app").first 47 | info_path = File.join(app_path, 'Info.plist') 48 | plist_data = File.read(info_path) 49 | plist = CFPropertyList::List.new(data: plist_data) 50 | parsed_data = CFPropertyList.native_types(plist.value) 51 | 52 | parsed_data['CFBundleExecutable'] 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/commands/order_files/validate_xcode_project.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'xcodeproj' 3 | 4 | module EmergeCLI 5 | module Commands 6 | module OrderFiles 7 | class ValidateXcodeProject < EmergeCLI::Commands::GlobalOptions 8 | desc 'Validate xcodeproject for order files' 9 | 10 | option :path, type: :string, required: true, desc: 'Path to the xcodeproject to validate' 11 | option :target, type: :string, required: false, desc: 'Target to validate' 12 | option :build_configuration, type: :string, required: false, 13 | desc: 'Build configuration to validate (Release by default)' 14 | 15 | # Constants 16 | LINK_MAPS_CONFIG = 'LD_GENERATE_MAP_FILE'.freeze 17 | LINK_MAPS_PATH = 'LD_MAP_FILE_PATH'.freeze 18 | PATH_TO_LINKMAP = '$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt'.freeze 19 | 20 | def call(**options) 21 | @options = options 22 | before(options) 23 | 24 | raise 'Path must be an xcodeproject' unless @options[:path].end_with?('.xcodeproj') 25 | raise 'Path does not exist' unless File.exist?(@options[:path]) 26 | 27 | @options[:build_configuration] ||= 'Release' 28 | 29 | Sync do 30 | project = Xcodeproj::Project.open(@options[:path]) 31 | 32 | validate_xcproj(project) 33 | end 34 | end 35 | 36 | private 37 | 38 | def validate_xcproj(project) 39 | project.targets.each do |target| 40 | next if @options[:target] && target.name != @options[:target] 41 | next unless target.product_type == 'com.apple.product-type.application' 42 | 43 | target.build_configurations.each do |config| 44 | next if config.name != @options[:build_configuration] 45 | validate_target_config(target, config) 46 | end 47 | end 48 | end 49 | 50 | def validate_target_config(target, config) 51 | has_error = false 52 | if config.build_settings[LINK_MAPS_CONFIG] != 'YES' 53 | has_error = true 54 | Logger.error "❌ Write Link Map File (#{LINK_MAPS_CONFIG}) is not set to YES" 55 | end 56 | if config.build_settings[LINK_MAPS_PATH] != '' 57 | has_error = true 58 | Logger.error "❌ Path to Link Map File (#{LINK_MAPS_PATH}) is not set, we recommend \ 59 | setting it to '#{PATH_TO_LINKMAP}'" 60 | end 61 | 62 | if has_error 63 | Logger.error "❌ Target '#{target.name}' has errors, this means \ 64 | that the linkmaps will not be generated as expected" 65 | Logger.error "Use `emerge configure order-files-ios --project-path '#{@options[:path]}'` to fix this" 66 | else 67 | Logger.info "✅ Target '#{target.name}' is valid" 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/commands/reaper/reaper.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'json' 3 | require 'tty-prompt' 4 | 5 | module EmergeCLI 6 | module Commands 7 | class Reaper < EmergeCLI::Commands::GlobalOptions 8 | desc 'Analyze dead code from an Emerge upload' 9 | 10 | option :id, type: :string, required: true, desc: 'Emerge build ID to analyze' 11 | option :project_root, type: :string, required: true, 12 | desc: 'Root directory of the project, defaults to current directory' 13 | 14 | option :api_token, type: :string, required: false, 15 | desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]' 16 | 17 | option :profile, type: :boolean, default: false, desc: 'Enable performance profiling metrics' 18 | 19 | option :skip_delete_usages, type: :boolean, default: false, 20 | desc: 'Skip deleting usages of the type (experimental feature)' 21 | 22 | def initialize(network: nil) 23 | @network = network 24 | end 25 | 26 | def call(**options) 27 | @options = options 28 | @profiler = EmergeCLI::Profiler.new(enabled: options[:profile]) 29 | @prompt = TTY::Prompt.new 30 | before(options) 31 | success = false 32 | 33 | begin 34 | api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil) 35 | raise 'API token is required' unless api_token 36 | 37 | @network ||= EmergeCLI::Network.new(api_token:) 38 | project_root = @options[:project_root] || Dir.pwd 39 | 40 | Sync do 41 | all_data = @profiler.measure('fetch_dead_code') { fetch_all_dead_code(@options[:id]) } 42 | result = @profiler.measure('parse_dead_code') { DeadCodeResult.new(all_data) } 43 | 44 | Logger.info result.to_s 45 | 46 | selected_types = prompt_class_selection(result.filtered_unseen_classes, result.metadata['platform']) 47 | Logger.info 'Selected classes:' 48 | selected_types.each do |selected_class| 49 | Logger.info " - #{selected_class['class_name']}" 50 | end 51 | 52 | confirmed = confirm_deletion(selected_types.length) 53 | if !confirmed 54 | Logger.info 'Operation cancelled' 55 | return false 56 | end 57 | 58 | Logger.info 'Proceeding with deletion...' 59 | platform = result.metadata['platform'] 60 | deleter = EmergeCLI::Reaper::CodeDeleter.new( 61 | project_root: project_root, 62 | platform: platform, 63 | profiler: @profiler, 64 | skip_delete_usages: options[:skip_delete_usages] 65 | ) 66 | @profiler.measure('delete_types') { deleter.delete_types(selected_types) } 67 | end 68 | 69 | @profiler.report if @options[:profile] 70 | success = true 71 | rescue StandardError => e 72 | Logger.error "Failed to analyze dead code: #{e.message}" 73 | raise e 74 | ensure 75 | @network&.close 76 | end 77 | 78 | success 79 | end 80 | 81 | private 82 | 83 | def fetch_all_dead_code(upload_id) 84 | Logger.info 'Fetching dead code analysis (this may take a while for large codebases)...' 85 | 86 | page = 1 87 | combined_data = nil 88 | 89 | loop do 90 | response = fetch_dead_code_page(upload_id, page) 91 | data = JSON.parse(response.read) 92 | 93 | if combined_data.nil? 94 | combined_data = data 95 | else 96 | combined_data['dead_code'].concat(data.fetch('dead_code', [])) 97 | end 98 | 99 | current_page = data.dig('pagination', 'current_page') 100 | total_pages = data.dig('pagination', 'total_pages') 101 | 102 | break unless current_page && total_pages && current_page < total_pages 103 | 104 | page += 1 105 | Logger.info "Fetching page #{page} of #{total_pages}..." 106 | end 107 | 108 | combined_data 109 | end 110 | 111 | def fetch_dead_code_page(upload_id, page) 112 | @network.post( 113 | path: '/deadCode/export', 114 | query: { 115 | uploadId: upload_id, 116 | page: page 117 | }, 118 | headers: { 'Accept' => 'application/json' }, 119 | body: nil 120 | ) 121 | end 122 | 123 | def prompt_class_selection(unseen_classes, platform) 124 | return nil if unseen_classes.empty? 125 | 126 | choices = unseen_classes.map do |item| 127 | display_name = if item['paths']&.first && platform == 'ios' 128 | "#{item['class_name']} (#{item['paths'].first})" 129 | else 130 | item['class_name'] 131 | end 132 | { 133 | name: display_name, 134 | value: item 135 | } 136 | end 137 | 138 | @prompt.multi_select( 139 | 'Select classes to delete:'.blue, 140 | choices, 141 | per_page: 15, 142 | echo: false, 143 | filter: true, 144 | min: 1 145 | ) 146 | end 147 | 148 | def confirm_deletion(count) 149 | @prompt.yes?("Are you sure you want to delete #{count} type#{count > 1 ? 's' : ''}?") 150 | end 151 | 152 | class DeadCodeResult 153 | attr_reader :metadata, :dead_code, :counts, :pagination 154 | 155 | def initialize(data) 156 | @metadata = data['metadata'] 157 | @dead_code = data['dead_code'] 158 | @counts = data['counts'] 159 | @pagination = data['pagination'] 160 | end 161 | 162 | def filtered_unseen_classes 163 | @filtered_unseen_classes ||= dead_code 164 | .reject { |item| item['seen'] } 165 | .reject do |item| 166 | paths = item['paths'] 167 | next false if paths.nil? || paths.empty? 168 | 169 | next true if paths.any? do |path| 170 | path.include?('/SourcePackages/checkouts/') || 171 | path.include?('/Pods/') || 172 | path.include?('/Carthage/') || 173 | path.include?('/Vendor/') || 174 | path.include?('/Sources/') || 175 | path.include?('/DerivedSources/') 176 | end 177 | 178 | next false if paths.none? do |path| 179 | path.end_with?('.swift', '.java', '.kt') 180 | end 181 | end 182 | end 183 | 184 | def to_s 185 | <<~SUMMARY.yellow 186 | 187 | Dead Code Analysis Results: 188 | App ID: #{@metadata['app_id']} 189 | App Version: #{@metadata['version']} 190 | Platform: #{@metadata['platform']} 191 | 192 | Statistics: 193 | - Total User Sessions: #{@counts['user_sessions']} 194 | - Seen Classes: #{@counts['seen_classes']} 195 | - Unseen Classes: #{@counts['unseen_classes']} 196 | SUMMARY 197 | end 198 | end 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /lib/commands/snapshots/validate_app.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'json' 3 | require 'uri' 4 | require 'yaml' 5 | require 'cfpropertylist' 6 | 7 | module EmergeCLI 8 | module Commands 9 | module Snapshots 10 | class ValidateApp < EmergeCLI::Commands::GlobalOptions 11 | desc 'Validate app for snapshot testing [iOS, macOS]' 12 | 13 | # Optional options 14 | option :path, type: :string, required: true, desc: 'Path to the app binary or xcarchive' 15 | 16 | # Mangled names are deterministic, no need to demangle them 17 | SWIFT_PREVIEWS_MANGLED_NAMES = [ 18 | '_$s21DeveloperToolsSupport15PreviewRegistryMp', 19 | '_$s7SwiftUI15PreviewProviderMp' 20 | ].freeze 21 | 22 | def call(**options) 23 | @options = options 24 | before(options) 25 | 26 | Sync do 27 | binary_path = get_binary_path 28 | Logger.info "Found binary: #{binary_path}" 29 | 30 | Logger.info "Loading binary: #{binary_path}" 31 | macho_parser = MachOParser.new 32 | macho_parser.load_binary(binary_path) 33 | 34 | use_chained_fixups, imported_symbols = macho_parser.read_linkedit_data_command 35 | bound_symbols = macho_parser.read_dyld_info_only_command 36 | 37 | found = macho_parser.find_protocols_in_swift_proto(use_chained_fixups, imported_symbols, bound_symbols, 38 | SWIFT_PREVIEWS_MANGLED_NAMES) 39 | 40 | if found 41 | Logger.info '✅ Found SwiftUI previews' 42 | else 43 | Logger.error '❌ No SwiftUI previews found' 44 | end 45 | found 46 | end 47 | end 48 | 49 | private 50 | 51 | def get_binary_path 52 | return @options[:path] unless @options[:path].end_with?('.xcarchive') 53 | app_path = Dir.glob("#{@options[:path]}/Products/Applications/*.app").first 54 | info_path = File.join(app_path, 'Info.plist') 55 | plist_data = File.read(info_path) 56 | plist = CFPropertyList::List.new(data: plist_data) 57 | parsed_data = CFPropertyList.native_types(plist.value) 58 | 59 | File.join(app_path, parsed_data['CFBundleExecutable']) 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/commands/upload/build.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'json' 3 | require 'uri' 4 | require 'async' 5 | require 'async/barrier' 6 | require 'async/semaphore' 7 | require 'async/http/internet/instance' 8 | 9 | module EmergeCLI 10 | module Commands 11 | module Upload 12 | class Build < EmergeCLI::Commands::GlobalOptions 13 | desc 'Upload a build to Emerge' 14 | 15 | option :path, type: :string, required: true, desc: 'Path to the build artifact' 16 | 17 | # Optional options 18 | option :api_token, type: :string, required: false, 19 | desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]' 20 | option :sha, type: :string, required: false, desc: 'SHA of the commit' 21 | option :branch, type: :string, required: false, desc: 'Branch name' 22 | option :repo_name, type: :string, required: false, desc: 'Repository name' 23 | option :base_sha, type: :string, required: false, desc: 'Base SHA' 24 | option :previous_sha, type: :string, required: false, desc: 'Previous SHA' 25 | option :pr_number, type: :string, required: false, desc: 'PR number' 26 | 27 | def initialize(network: nil, git_info_provider: nil) 28 | @network = network 29 | @git_info_provider = git_info_provider 30 | end 31 | 32 | def call(**options) 33 | @options = options 34 | @profiler = EmergeCLI::Profiler.new(enabled: options[:profile]) 35 | before(options) 36 | 37 | start_time = Time.now 38 | 39 | file_path = options[:path] 40 | file_exists = File.exist?(file_path) 41 | raise "File not found at path: #{file_path}" unless file_exists 42 | 43 | file_extension = File.extname(file_path) 44 | raise "Unsupported file type: #{file_extension}" unless ['.ipa', '.apk', '.aab', 45 | '.zip'].include?(file_extension) 46 | 47 | api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil) 48 | raise 'API token is required and cannot be blank' if api_token.nil? || api_token.strip.empty? 49 | 50 | @network ||= EmergeCLI::Network.new(api_token:) 51 | @git_info_provider ||= GitInfoProvider.new 52 | 53 | Sync do 54 | upload_url, upload_id = fetch_upload_url 55 | 56 | file_size = File.size(file_path) 57 | Logger.info("Uploading file... (#{file_size} bytes)") 58 | 59 | File.open(file_path, 'rb') do |file| 60 | headers = { 61 | 'Content-Type' => 'application/zip', 62 | 'Content-Length' => file_size.to_s 63 | } 64 | 65 | response = @network.put( 66 | path: upload_url, 67 | body: file.read, 68 | headers: headers 69 | ) 70 | 71 | unless response.status == 200 72 | Logger.error("Upload failed with status #{response.status}") 73 | Logger.error("Response body: #{response.body}") 74 | raise "Uploading file failed with status #{response.status}" 75 | end 76 | end 77 | 78 | Logger.info('Upload complete successfully!') 79 | Logger.info "Time taken: #{(Time.now - start_time).round(2)} seconds" 80 | Logger.info("✅ You can view the build analysis at https://emergetools.com/build/#{upload_id}") 81 | end 82 | end 83 | 84 | private 85 | 86 | def fetch_upload_url 87 | git_result = @git_info_provider.fetch_git_info 88 | sha = @options[:sha] || git_result.sha 89 | branch = @options[:branch] || git_result.branch 90 | base_sha = @options[:base_sha] || git_result.base_sha 91 | previous_sha = @options[:previous_sha] || git_result.previous_sha 92 | pr_number = @options[:pr_number] || git_result.pr_number 93 | 94 | # TODO: Make optional 95 | raise 'SHA is required' unless sha 96 | raise 'Branch is required' unless branch 97 | 98 | payload = { 99 | sha:, 100 | branch:, 101 | repo_name: @options[:repo_name], 102 | # Optional 103 | base_sha:, 104 | previous_sha:, 105 | pr_number: pr_number&.to_s 106 | }.compact 107 | 108 | upload_response = @network.post( 109 | path: '/upload', 110 | body: payload, 111 | headers: { 'Content-Type' => 'application/json' } 112 | ) 113 | upload_json = parse_response(upload_response) 114 | upload_id = upload_json.fetch('upload_id') 115 | upload_url = upload_json.fetch('uploadURL') 116 | Logger.debug("Got upload ID: #{upload_id}") 117 | 118 | warning = upload_json['warning'] 119 | Logger.warn(warning) if warning 120 | 121 | [upload_url, upload_id] 122 | end 123 | 124 | def parse_response(response) 125 | case response.status 126 | when 200 127 | JSON.parse(response.read) 128 | when 400 129 | error_message = JSON.parse(response.read)['errorMessage'] 130 | raise "Invalid parameters: #{error_message}" 131 | when 401, 403 132 | raise 'Invalid API token' 133 | else 134 | raise "Creating upload failed with status #{response.status}" 135 | end 136 | end 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/commands/upload/snapshots/client_libraries/default.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | module Commands 3 | module Upload 4 | module ClientLibraries 5 | class Default 6 | def initialize(image_paths, group_delimiter) 7 | @image_paths = image_paths 8 | @group_delimiter = group_delimiter 9 | end 10 | 11 | def image_files 12 | @image_paths.flat_map { |path| Dir.glob("#{path}/**/*.png") } 13 | end 14 | 15 | def parse_file_info(image_path) 16 | file_name = File.basename(image_path) 17 | file_name_without_extension = File.basename(file_name, '.*') 18 | parts = file_name_without_extension.split(@group_delimiter) 19 | group_name = parts.first 20 | variant_name = parts[1..].join(@group_delimiter) 21 | { 22 | file_name:, 23 | group_name:, 24 | variant_name: 25 | } 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/commands/upload/snapshots/client_libraries/paparazzi.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | module Commands 3 | module Upload 4 | module ClientLibraries 5 | class Paparazzi 6 | def initialize(project_root) 7 | @project_root = project_root 8 | end 9 | 10 | def image_files 11 | # TODO: support "paparazzi.snapshot.dir" dynamic config 12 | Dir.glob(File.join(@project_root, '**/src/test/snapshots/images/**/*.png')) 13 | end 14 | 15 | def parse_file_info(image_path) 16 | file_name = image_path.split('src/test/snapshots/images/').last 17 | test_class_name = File.basename(File.dirname(image_path)) 18 | 19 | { 20 | file_name:, 21 | group_name: test_class_name, # TODO: add support for nicer group names 22 | variant_name: File.basename(file_name, '.*') 23 | } 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/commands/upload/snapshots/client_libraries/roborazzi.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | module Commands 3 | module Upload 4 | module ClientLibraries 5 | class Roborazzi 6 | def initialize(project_root) 7 | @project_root = project_root 8 | end 9 | 10 | def image_files 11 | Dir.glob(File.join(@project_root, '**/build/outputs/roborazzi/**/*.png')) 12 | end 13 | 14 | def parse_file_info(image_path) 15 | file_name = image_path.split('build/outputs/roborazzi/').last 16 | base_name = File.basename(file_name, '.png') 17 | parts = base_name.split('.') 18 | 19 | # Get the last two parts regardless of whether there's a package name in the file name 20 | # For "com.example.MyTest.testName" -> ["MyTest", "testName"] 21 | # For "MyTest.testName" -> ["MyTest", "testName"] 22 | relevant_parts = parts.last(2) 23 | 24 | { 25 | file_name:, 26 | group_name: relevant_parts[0], 27 | variant_name: relevant_parts[1] 28 | } 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/commands/upload/snapshots/client_libraries/swift_snapshot_testing.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | module Commands 3 | module Upload 4 | module ClientLibraries 5 | class SwiftSnapshotTesting 6 | def initialize(project_root) 7 | @project_root = project_root 8 | end 9 | 10 | def image_files 11 | Dir.glob(File.join(@project_root, '**/__Snapshots__/**/*.png')) 12 | end 13 | 14 | def parse_file_info(image_path) 15 | file_name = image_path.split('__Snapshots__/').last 16 | test_class_name = File.basename(File.dirname(image_path)) 17 | 18 | { 19 | file_name:, 20 | group_name: test_class_name.sub(/Tests$/, ''), 21 | variant_name: File.basename(file_name, '.*') 22 | } 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/emerge_cli.rb: -------------------------------------------------------------------------------- 1 | require_relative 'version' 2 | 3 | require_relative 'commands/global_options' 4 | require_relative 'commands/build/distribution/validate' 5 | require_relative 'commands/build/distribution/install' 6 | require_relative 'commands/config/snapshots/snapshots_ios' 7 | require_relative 'commands/config/orderfiles/orderfiles_ios' 8 | require_relative 'commands/integrate/fastlane' 9 | require_relative 'commands/fix/minify_strings' 10 | require_relative 'commands/fix/strip_binary_symbols' 11 | require_relative 'commands/fix/exported_symbols' 12 | require_relative 'commands/order_files/download_order_files' 13 | require_relative 'commands/order_files/validate_linkmaps' 14 | require_relative 'commands/order_files/validate_xcode_project' 15 | require_relative 'commands/reaper/reaper' 16 | require_relative 'commands/snapshots/validate_app' 17 | require_relative 'commands/upload/build' 18 | require_relative 'commands/upload/snapshots/snapshots' 19 | require_relative 'commands/upload/snapshots/client_libraries/swift_snapshot_testing' 20 | require_relative 'commands/upload/snapshots/client_libraries/paparazzi' 21 | require_relative 'commands/upload/snapshots/client_libraries/roborazzi' 22 | require_relative 'commands/upload/snapshots/client_libraries/default' 23 | 24 | require_relative 'reaper/ast_parser' 25 | require_relative 'reaper/code_deleter' 26 | 27 | require_relative 'utils/environment' 28 | require_relative 'utils/git_info_provider' 29 | require_relative 'utils/git_result' 30 | require_relative 'utils/github' 31 | require_relative 'utils/git' 32 | require_relative 'utils/logger' 33 | require_relative 'utils/network' 34 | require_relative 'utils/profiler' 35 | require_relative 'utils/project_detector' 36 | require_relative 'utils/macho_parser' 37 | require_relative 'utils/version_check' 38 | require_relative 'utils/xcode_device_manager' 39 | require_relative 'utils/xcode_simulator' 40 | require_relative 'utils/xcode_physical_device' 41 | 42 | require 'dry/cli' 43 | 44 | module EmergeCLI 45 | extend Dry::CLI::Registry 46 | 47 | register 'configure' do |prefix| 48 | prefix.register 'snapshots-ios', Commands::Config::SnapshotsIOS 49 | prefix.register 'order-files-ios', Commands::Config::OrderFilesIOS 50 | end 51 | 52 | register 'download' do |prefix| 53 | prefix.register 'order-files', Commands::OrderFiles::Download 54 | end 55 | 56 | register 'fix' do |prefix| 57 | prefix.register 'minify-strings', Commands::Fix::MinifyStrings 58 | prefix.register 'strip-binary-symbols', Commands::Fix::StripBinarySymbols 59 | prefix.register 'exported-symbols', Commands::Fix::ExportedSymbols 60 | end 61 | 62 | register 'integrate' do |prefix| 63 | prefix.register 'fastlane-ios', Commands::Integrate::Fastlane, aliases: ['i'] 64 | end 65 | 66 | register 'install' do |prefix| 67 | prefix.register 'build', Commands::Build::Distribution::Install 68 | end 69 | 70 | # TODO: make this command action oriented 71 | register 'reaper', Commands::Reaper 72 | 73 | register 'upload', aliases: ['u'] do |prefix| 74 | prefix.register 'build', Commands::Upload::Build 75 | prefix.register 'snapshots', Commands::Upload::Snapshots 76 | end 77 | 78 | register 'validate' do |prefix| 79 | prefix.register 'build-distribution', Commands::Build::Distribution::ValidateApp 80 | prefix.register 'order-files-linkmaps', Commands::OrderFiles::ValidateLinkmaps 81 | prefix.register 'order-files-xcode-project', Commands::OrderFiles::ValidateXcodeProject 82 | prefix.register 'snapshots-app-ios', Commands::Snapshots::ValidateApp 83 | end 84 | end 85 | 86 | # By default the log level is INFO, but can be overridden by the --debug flag 87 | EmergeCLI::Logger.configure(Logger::INFO) 88 | -------------------------------------------------------------------------------- /lib/reaper/code_deleter.rb: -------------------------------------------------------------------------------- 1 | require 'xcodeproj' 2 | 3 | module EmergeCLI 4 | module Reaper 5 | class CodeDeleter 6 | def initialize(project_root:, platform:, profiler:, skip_delete_usages: false) 7 | @project_root = File.expand_path(project_root) 8 | @platform = platform 9 | @profiler = profiler 10 | @skip_delete_usages = skip_delete_usages 11 | Logger.debug "Initialized CodeDeleter with project root: #{@project_root}, platform: #{@platform}" 12 | end 13 | 14 | def delete_types(types) 15 | Logger.debug "Project root: #{@project_root}" 16 | 17 | types.each do |class_info| 18 | Logger.info "Deleting #{class_info['class_name']}" 19 | 20 | type_name_result = parse_type_name(class_info['class_name']) 21 | type_name = type_name_result[:type_name] 22 | package_name = type_name_result[:package_name] 23 | Logger.debug "Parsed type name: #{type_name}" 24 | 25 | # Remove line number from path if present 26 | paths = class_info['paths']&.map { |path| path.sub(/:\d+$/, '') } 27 | found_usages = @profiler.measure('find_type_in_project') do 28 | find_type_in_project(type_name) 29 | end 30 | 31 | if paths.nil? || paths.empty? 32 | Logger.info "No paths provided for #{type_name}, using found usages instead..." 33 | paths = found_usages 34 | .select { |usage| usage[:usages].any? { |u| u[:usage_type] == 'declaration' } } 35 | .map { |usage| usage[:path] } 36 | if paths.empty? 37 | Logger.warn "Could not find any files containing #{type_name}" 38 | next 39 | end 40 | Logger.info "Found #{type_name} in: #{paths.join(', ')}" 41 | end 42 | 43 | # First pass: Delete declarations 44 | paths.each do |path| 45 | Logger.debug "Processing path: #{path}" 46 | @profiler.measure('delete_type_from_file') do 47 | delete_type_from_file(path, type_name, package_name) 48 | end 49 | end 50 | 51 | # Second pass: Delete remaining usages (unless skipped) 52 | if @skip_delete_usages 53 | Logger.info 'Skipping delete usages' 54 | else 55 | identifier_usages = found_usages.select do |usage| 56 | usage[:usages].any? { |u| u[:usage_type] == 'identifier' } 57 | end 58 | identifier_usage_paths = identifier_usages.map { |usage| usage[:path] }.uniq 59 | if identifier_usage_paths.empty? 60 | Logger.info 'No identifier usages found, skipping delete usages' 61 | else 62 | identifier_usage_paths.each do |path| 63 | Logger.debug "Processing usages in path: #{path}" 64 | @profiler.measure('delete_usages_from_file') do 65 | delete_usages_from_file(path, type_name) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | end 72 | 73 | private 74 | 75 | def parse_type_name(type_name) 76 | package_name = nil 77 | 78 | # Remove first module prefix for Swift types if present 79 | if @platform == 'ios' && type_name.include?('.') 80 | parsed_type_name = type_name.split('.')[1..].join('.') 81 | # For Android, strip package name and just use the class name 82 | elsif @platform == 'android' && type_name.include?('.') 83 | # rubocop:disable Layout/LineLength 84 | # Handle cases like "com.emergetools.hackernews.data.remote.ItemResponse $NullResponse (HackerNewsBaseClient.kt)" 85 | # rubocop:enable Layout/LineLength 86 | has_nested_class = type_name.include?('$') 87 | parts = type_name.split 88 | 89 | if parts.length == 0 90 | parsed_type_name = type_name 91 | elsif has_nested_class && parts.length > 1 92 | full_class_path = parts[0].split('.') 93 | base_name = full_class_path.last 94 | nested_class = parts[1].match(/\$(.+)/).captures.first 95 | parsed_type_name = "#{base_name}.#{nested_class}" 96 | # Extract package name (everything except the last part) 97 | package_name = full_class_path[0...-1].join('.') if full_class_path.length > 1 98 | else 99 | full_class_path = parts[0].split('.') 100 | parsed_type_name = full_class_path.last 101 | # Extract package name (everything except the last part) 102 | package_name = full_class_path[0...-1].join('.') if full_class_path.length > 1 103 | end 104 | else 105 | parsed_type_name = type_name 106 | end 107 | 108 | { type_name: parsed_type_name, package_name: package_name } 109 | end 110 | 111 | def delete_type_from_file(path, type_name, marker = nil) 112 | full_path = resolve_file_path(path, marker) 113 | return unless full_path 114 | 115 | Logger.debug "Processing file: #{full_path}" 116 | begin 117 | original_contents = @profiler.measure('read_file') { File.read(full_path) } 118 | parser = make_parser_for_file(full_path) 119 | modified_contents = @profiler.measure('parse_and_delete_type') do 120 | parser.delete_type( 121 | file_contents: original_contents, 122 | type_name: type_name 123 | ) 124 | end 125 | 126 | if modified_contents.nil? 127 | @profiler.measure('delete_file') do 128 | File.delete(full_path) 129 | end 130 | if parser.language == 'swift' 131 | @profiler.measure('delete_type_from_xcode_project') do 132 | delete_type_from_xcode_project(full_path) 133 | end 134 | end 135 | Logger.info "Deleted file #{full_path} as it only contained #{type_name}" 136 | elsif modified_contents != original_contents 137 | @profiler.measure('write_file') do 138 | File.write(full_path, modified_contents) 139 | end 140 | Logger.info "Successfully deleted #{type_name} from #{full_path}" 141 | else 142 | Logger.warn "No changes made to #{full_path} for #{type_name}" 143 | end 144 | rescue StandardError => e 145 | Logger.error "Failed to delete #{type_name} from #{full_path}: #{e.message}" 146 | Logger.error e.backtrace.join("\n") 147 | end 148 | end 149 | 150 | def delete_type_from_xcode_project(file_path) 151 | xcodeproj_path = Dir.glob(File.join(@project_root, '**/*.xcodeproj')).first 152 | if xcodeproj_path.nil? 153 | Logger.warn "No Xcode project found in #{@project_root}" 154 | return 155 | end 156 | 157 | begin 158 | project = Xcodeproj::Project.open(xcodeproj_path) 159 | relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@project_root)).to_s 160 | 161 | file_ref = project.files.find { |f| f.real_path.to_s.end_with?(relative_path) } 162 | if file_ref 163 | file_ref.remove_from_project 164 | project.save 165 | Logger.info "Removed #{relative_path} from Xcode project" 166 | else 167 | Logger.warn "Could not find #{relative_path} in Xcode project" 168 | end 169 | rescue StandardError => e 170 | Logger.error "Failed to update Xcode project: #{e.message}" 171 | Logger.error e.backtrace.join("\n") 172 | end 173 | end 174 | 175 | def find_type_in_project(type_name) 176 | found_usages = [] 177 | source_patterns = case @platform&.downcase 178 | when 'ios' 179 | { 'swift' => '**/*.swift', 180 | 'objc' => '**/*.{m,h}' } 181 | when 'android' 182 | { 183 | 'kotlin' => '**/*.kt', 184 | 'java' => '**/*.java' 185 | } 186 | else 187 | raise "Unsupported platform: #{@platform}" 188 | end 189 | 190 | source_patterns.each do |language, pattern| 191 | Dir.glob(File.join(@project_root, pattern)).reject { |path| path.include?('/build/') }.each do |file_path| 192 | Logger.debug "Scanning #{file_path} for #{type_name}" 193 | contents = File.read(file_path) 194 | parser = make_parser_for_file(file_path) 195 | usages = parser.find_usages(file_contents: contents, type_name: type_name) 196 | 197 | if usages.any? 198 | Logger.debug "✅ Found #{type_name} in #{file_path}" 199 | relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@project_root)).to_s 200 | found_usages << { 201 | path: relative_path, 202 | usages: usages, 203 | language: language 204 | } 205 | end 206 | rescue StandardError => e 207 | Logger.warn "Error scanning #{file_path}: #{e.message}" 208 | end 209 | end 210 | 211 | found_usages 212 | end 213 | 214 | def delete_usages_from_file(path, type_name) 215 | full_path = resolve_file_path(path) 216 | return unless full_path 217 | 218 | begin 219 | original_contents = File.read(full_path) 220 | parser = make_parser_for_file(full_path) 221 | Logger.debug "Deleting usages of #{type_name} from #{full_path}" 222 | modified_contents = parser.delete_usage( 223 | file_contents: original_contents, 224 | type_name: type_name 225 | ) 226 | 227 | if modified_contents != original_contents 228 | File.write(full_path, modified_contents) 229 | Logger.info "Successfully removed usages of #{type_name} from #{full_path}" 230 | end 231 | rescue StandardError => e 232 | Logger.error "Failed to delete usages of #{type_name} from #{full_path}: #{e.message}" 233 | Logger.error e.backtrace.join("\n") 234 | end 235 | end 236 | 237 | def resolve_file_path(path, marker = nil) 238 | # If path starts with /, treat it as relative to project root 239 | if path.start_with?('/') 240 | path = path[1..] # Remove leading slash 241 | full_path = File.join(@project_root, path) 242 | return full_path if File.exist?(full_path) 243 | end 244 | 245 | # Try direct path first 246 | full_path = File.join(@project_root, path) 247 | return full_path if File.exist?(full_path) 248 | 249 | # If not found, search recursively 250 | Logger.debug "File not found at #{full_path}, searching in project..." 251 | matching_files = Dir.glob(File.join(@project_root, '**', path)) 252 | .reject { |p| p.include?('/build/') } 253 | 254 | if matching_files.empty? 255 | Logger.warn "Could not find #{path} in project" 256 | return nil 257 | elsif matching_files.length > 1 258 | Logger.debug "Found multiple matches for #{path}: #{matching_files.join(', ')}" 259 | 260 | # If a marker is provided, use it to select the file 261 | # For Android, this is the package name declaration of the type 262 | if marker 263 | Logger.debug "Using marker #{marker} to select file" 264 | marker_files = matching_files.select { |file| File.read(file).include?(marker) } 265 | if marker_files.length >= 1 266 | Logger.info "Found #{marker_files.length} files with marker #{marker} for #{path}, using first match" 267 | return marker_files.first 268 | else 269 | Logger.warn "No files found with marker #{marker} for #{path}" 270 | end 271 | end 272 | 273 | Logger.warn "Using first match: #{matching_files.first}" 274 | end 275 | 276 | matching_files.first 277 | end 278 | 279 | def make_parser_for_file(file_path) 280 | language = case File.extname(file_path) 281 | when '.swift' then 'swift' 282 | when '.kt' then 'kotlin' 283 | when '.java' then 'java' 284 | when '.m', '.h' then 'objc' 285 | else 286 | raise "Unsupported file type for #{file_path}" 287 | end 288 | AstParser.new(language) 289 | end 290 | end 291 | end 292 | end 293 | -------------------------------------------------------------------------------- /lib/utils/environment.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | class Environment 3 | def execute_command(command) 4 | `#{command}` 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/utils/git.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | 3 | module EmergeCLI 4 | module Git 5 | def self.branch 6 | Logger.debug 'Getting current branch name' 7 | command = 'git rev-parse --abbrev-ref HEAD' 8 | Logger.debug command 9 | stdout, _, status = Open3.capture3(command) 10 | unless status.success? 11 | Logger.error 'Failed to get the current branch name' 12 | return nil 13 | end 14 | 15 | branch_name = stdout.strip 16 | if branch_name == 'HEAD' 17 | # We're in a detached HEAD state 18 | # Find all branches that contains the current HEAD commit 19 | # 20 | # Example output: 21 | # * (HEAD detached at dec13a5) 22 | # telkins/detached-test 23 | # remotes/origin/telkins/detached-test 24 | # 25 | # So far I've seen this output be fairly stable 26 | # If the input is invalid for whatever reason, sed/awk will return an empty string 27 | command = "git branch -a --contains HEAD | sed -n 2p | awk '{ printf $1 }'" 28 | Logger.debug command 29 | head_stdout, _, head_status = Open3.capture3(command) 30 | 31 | unless head_status.success? 32 | Logger.error 'Failed to get the current branch name for detached HEAD' 33 | return nil 34 | end 35 | 36 | branch_name = head_stdout.strip 37 | end 38 | 39 | branch_name == 'HEAD' ? nil : branch_name 40 | end 41 | 42 | def self.sha 43 | Logger.debug 'Getting current SHA' 44 | command = 'git rev-parse HEAD' 45 | Logger.debug command 46 | stdout, _, status = Open3.capture3(command) 47 | stdout.strip if status.success? 48 | end 49 | 50 | def self.base_sha 51 | Logger.debug 'Getting base SHA' 52 | current_branch = branch 53 | remote_head = remote_head_branch 54 | return nil if current_branch.nil? || remote_head.nil? 55 | 56 | command = "git merge-base #{remote_head} #{current_branch}" 57 | Logger.debug command 58 | stdout, _, status = Open3.capture3(command) 59 | return nil if stdout.strip.empty? || !status.success? 60 | current_sha = sha 61 | stdout.strip == current_sha ? nil : stdout.strip 62 | end 63 | 64 | def self.previous_sha 65 | Logger.debug 'Getting previous SHA' 66 | command = 'git rev-list --count HEAD' 67 | Logger.debug command 68 | count_stdout, _, count_status = Open3.capture3(command) 69 | 70 | if !count_status.success? || count_stdout.strip.to_i <= 1 71 | Logger.error 'Detected shallow clone while trying to get the previous commit. ' \ 72 | 'Please clone with full history using: git clone --no-single-branch ' \ 73 | 'or configure CI with fetch-depth: 0' 74 | return nil 75 | end 76 | 77 | command = 'git rev-parse HEAD^' 78 | Logger.debug command 79 | stdout, stderr, status = Open3.capture3(command) 80 | Logger.error "Failed to get previous SHA: #{stdout}, #{stderr}" if !status.success? 81 | stdout.strip if status.success? 82 | end 83 | 84 | def self.primary_remote 85 | Logger.debug 'Getting primary remote' 86 | remote = remote() 87 | return nil if remote.nil? 88 | remote.include?('origin') ? 'origin' : remote.first 89 | end 90 | 91 | def self.remote_head_branch(remote = primary_remote) 92 | Logger.debug 'Getting remote head branch' 93 | return nil if remote.nil? 94 | command = "git remote show #{remote}" 95 | Logger.debug command 96 | stdout, _, status = Open3.capture3(command) 97 | return nil if stdout.nil? || !status.success? 98 | stdout 99 | .split("\n") 100 | .map(&:strip) 101 | .find { |line| line.start_with?('HEAD branch: ') } 102 | &.split 103 | &.last 104 | end 105 | 106 | def self.remote_url(remote = primary_remote) 107 | Logger.debug 'Getting remote URL' 108 | return nil if remote.nil? 109 | command = "git config --get remote.#{remote}.url" 110 | Logger.debug command 111 | stdout, _, status = Open3.capture3(command) 112 | stdout if status.success? 113 | end 114 | 115 | def self.remote 116 | Logger.debug 'Getting remote' 117 | command = 'git remote' 118 | Logger.debug command 119 | stdout, _, status = Open3.capture3(command) 120 | stdout.split("\n") if status.success? 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/utils/git_info_provider.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | class GitInfoProvider 3 | def fetch_git_info 4 | if EmergeCLI::Github.supported_github_event? 5 | Logger.info 'Fetching Git info from Github event' 6 | EmergeCLI::GitResult.new( 7 | sha: EmergeCLI::Github.sha, 8 | base_sha: EmergeCLI::Github.base_sha, 9 | branch: EmergeCLI::Github.branch, 10 | pr_number: EmergeCLI::Github.pr_number, 11 | repo_name: EmergeCLI::Github.repo_name, 12 | previous_sha: EmergeCLI::Github.previous_sha 13 | ) 14 | else 15 | Logger.info 'Fetching Git info from system Git' 16 | EmergeCLI::GitResult.new( 17 | sha: EmergeCLI::Git.sha, 18 | base_sha: EmergeCLI::Git.base_sha, 19 | branch: EmergeCLI::Git.branch, 20 | previous_sha: EmergeCLI::Git.previous_sha 21 | ) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/utils/git_result.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | class GitResult 3 | attr_accessor :sha, :base_sha, :previous_sha, :branch, :pr_number, :repo_name 4 | 5 | def initialize(sha:, base_sha:, branch:, pr_number: nil, repo_name: nil, previous_sha: nil) 6 | @pr_number = pr_number 7 | @sha = sha 8 | @base_sha = base_sha 9 | @previous_sha = previous_sha 10 | @branch = branch 11 | @repo_name = repo_name 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/utils/github.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | module Github 3 | GITHUB_EVENT_PR = 'pull_request'.freeze 4 | GITHUB_EVENT_PUSH = 'push'.freeze 5 | 6 | def self.event_name 7 | ENV.fetch('GITHUB_EVENT_NAME', nil) 8 | end 9 | 10 | def self.supported_github_event? 11 | Logger.info "GitHub event name: #{event_name}" 12 | pull_request? || push? 13 | end 14 | 15 | def self.pull_request? 16 | event_name == GITHUB_EVENT_PR 17 | end 18 | 19 | def self.push? 20 | event_name == GITHUB_EVENT_PUSH 21 | end 22 | 23 | def self.sha 24 | if push? 25 | ENV.fetch('GITHUB_SHA', nil) 26 | elsif pull_request? 27 | github_event_data.dig(:pull_request, :head, :sha) 28 | end 29 | end 30 | 31 | def self.base_sha 32 | return unless pull_request? 33 | github_event_data.dig(:pull_request, :base, :sha) 34 | end 35 | 36 | def self.pr_number 37 | pull_request? ? github_event_data[:number] : nil 38 | end 39 | 40 | def self.branch 41 | pull_request? ? github_event_data.dig(:pull_request, :head, :ref) : Git.branch 42 | end 43 | 44 | def self.repo_owner 45 | github_event_data.dig(:repository, :owner, :login) 46 | end 47 | 48 | def self.repo_name 49 | github_event_data.dig(:repository, :full_name) 50 | end 51 | 52 | def self.previous_sha 53 | return unless push? 54 | github_event_data[:before] 55 | end 56 | 57 | def self.github_event_data 58 | @github_event_data ||= begin 59 | github_event_path = ENV.fetch('GITHUB_EVENT_PATH', nil) 60 | Logger.error 'GITHUB_EVENT_PATH is not set' if github_event_path.nil? 61 | 62 | Logger.error "File #{github_event_path} doesn't exist" unless File.exist?(github_event_path) 63 | 64 | file_content = File.read(github_event_path) 65 | file_json = JSON.parse(file_content, symbolize_names: true) 66 | Logger.debug "Parsed GitHub event data: #{file_json.inspect}" 67 | 68 | file_json 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/utils/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'colored2' 3 | 4 | module EmergeCLI 5 | class Logger 6 | class << self 7 | def configure(log_level) 8 | logger.level = log_level 9 | end 10 | 11 | def info(message) 12 | log(:info, message) 13 | end 14 | 15 | def warn(message) 16 | log(:warn, message) 17 | end 18 | 19 | def error(message) 20 | log(:error, message) 21 | end 22 | 23 | def debug(message) 24 | log(:debug, message) 25 | end 26 | 27 | private 28 | 29 | def logger 30 | @logger ||= create_logger 31 | end 32 | 33 | def create_logger 34 | logger = ::Logger.new(STDOUT) 35 | logger.formatter = proc do |severity, datetime, _progname, msg| 36 | timestamp = datetime.strftime('%Y-%m-%d %H:%M:%S.%L') 37 | formatted_severity = severity.ljust(5) 38 | colored_message = case severity 39 | when 'INFO' 40 | msg.to_s.white 41 | when 'WARN' 42 | msg.to_s.yellow 43 | when 'ERROR' 44 | msg.to_s.red 45 | when 'DEBUG' 46 | msg.to_s.blue 47 | else 48 | msg.to_s 49 | end 50 | "[#{timestamp}] #{formatted_severity} -- : #{colored_message}\n" 51 | end 52 | logger 53 | end 54 | 55 | def log(level, message) 56 | logger.send(level, message) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/utils/macho_parser.rb: -------------------------------------------------------------------------------- 1 | require 'macho' 2 | 3 | module EmergeCLI 4 | class MachOParser 5 | TYPE_METADATA_KIND_MASK = 0x7 << 3 6 | TYPE_METADATA_KIND_SHIFT = 3 7 | 8 | # Bind Codes 9 | BIND_OPCODE_MASK = 0xF0 10 | BIND_IMMEDIATE_MASK = 0x0F 11 | BIND_OPCODE_DONE = 0x00 12 | BIND_OPCODE_SET_DYLIB_ORDINAL_IMM = 0x10 13 | BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB = 0x20 14 | BIND_OPCODE_SET_DYLIB_SPECIAL_IMM = 0x30 15 | BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM = 0x40 16 | BIND_OPCODE_SET_TYPE_IMM = 0x50 17 | BIND_OPCODE_SET_ADDEND_SLEB = 0x60 18 | BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB = 0x70 19 | BIND_OPCODE_ADD_ADDR_ULEB = 0x80 20 | BIND_OPCODE_DO_BIND = 0x90 21 | BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB = 0xA0 22 | BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED = 0xB0 23 | BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB = 0xC0 24 | 25 | UINT64_SIZE = 8 26 | UINT64_MAX_VALUE = 0xFFFFFFFFFFFFFFFF 27 | 28 | def load_binary(binary_path) 29 | @macho_file = MachO::MachOFile.new(binary_path) 30 | @binary_data = File.binread(binary_path) 31 | end 32 | 33 | def read_linkedit_data_command 34 | chained_fixups_command = nil 35 | @macho_file.load_commands.each do |lc| 36 | chained_fixups_command = lc if lc.type == :LC_DYLD_CHAINED_FIXUPS 37 | end 38 | 39 | if chained_fixups_command.nil? 40 | Logger.debug 'No LC_DYLD_CHAINED_FIXUPS found' 41 | return false, [] 42 | end 43 | 44 | # linkedit_data_command 45 | _, _, dataoff, datasize = @binary_data[chained_fixups_command.offset, 16].unpack('L(ptr) { ptr.unpack1('L<') >> 9 }] 61 | when 2 # DYLD_CHAINED_IMPORT_ADDEND 62 | [8, ->(ptr) { ptr.unpack1('L<') >> 9 }] 63 | when 3 # DYLD_CHAINED_IMPORT_ADDEND64 64 | [16, ->(ptr) { ptr.unpack1('Q<') >> 32 }] 65 | end 66 | 67 | # Extract imported symbol names 68 | imports_count.times do |i| 69 | import_offset = imports_start + (i * import_size) 70 | name_offset = name_offset_proc.call(@binary_data[import_offset, import_size]) 71 | name_start = symbols_start + name_offset 72 | name = read_null_terminated_string(@binary_data[name_start..]) 73 | imported_symbols << name 74 | end 75 | 76 | [true, imported_symbols] 77 | end 78 | 79 | def read_dyld_info_only_command 80 | dyld_info_only_command = nil 81 | @macho_file.load_commands.each do |lc| 82 | dyld_info_only_command = lc if lc.type == :LC_DYLD_INFO_ONLY 83 | end 84 | 85 | if dyld_info_only_command.nil? 86 | Logger.debug 'No LC_DYLD_INFO_ONLY found' 87 | return [] 88 | end 89 | 90 | bound_symbols = [] 91 | start_address = dyld_info_only_command.bind_off 92 | end_address = dyld_info_only_command.bind_off + dyld_info_only_command.bind_size 93 | current_address = start_address 94 | 95 | current_symbol = BoundSymbol.new(segment_offset: 0, library: nil, offset: 0, symbol: '') 96 | while current_address < end_address 97 | results, current_address, current_symbol = read_next_symbol(@binary_data, current_address, end_address, 98 | current_symbol) 99 | 100 | # Dup items to avoid pointer issues 101 | results.each do |res| 102 | bound_symbols << res.dup 103 | end 104 | end 105 | 106 | # Filter only swift symbols starting with _$s 107 | swift_symbols = bound_symbols.select { |bound_symbol| bound_symbol.symbol.start_with?('_$s') } 108 | 109 | load_commands = @macho_file.load_commands.select { |lc| lc.type == :LC_SEGMENT_64 || lc.type == :LC_SEGMENT } # rubocop:disable Naming/VariableNumber 110 | 111 | swift_symbols.each do |swift_symbol| 112 | swift_symbol.address = load_commands[swift_symbol.segment_offset].vmaddr + swift_symbol.offset 113 | end 114 | 115 | swift_symbols 116 | end 117 | 118 | def find_protocols_in_swift_proto(use_chained_fixups, imported_symbols, bound_symbols, search_symbols) 119 | found_section = nil 120 | @macho_file.segments.each do |segment| 121 | segment.sections.each do |section| 122 | if section.segname.strip == '__TEXT' && section.sectname.strip == '__swift5_proto' 123 | found_section = section 124 | break 125 | end 126 | end 127 | end 128 | 129 | unless found_section 130 | Logger.error 'The __swift5_proto section was not found.' 131 | return false 132 | end 133 | 134 | start = found_section.offset 135 | size = found_section.size 136 | offsets_list = parse_list(@binary_data, start, size) 137 | 138 | offsets_list.each do |relative_offset, offset_start| 139 | type_file_address = offset_start + relative_offset 140 | if type_file_address <= 0 || type_file_address >= @binary_data.size 141 | Logger.error 'Invalid protocol conformance offset' 142 | next 143 | end 144 | 145 | # ProtocolConformanceDescriptor -> ProtocolDescriptor 146 | protocol_descriptor = read_little_endian_signed_integer(@binary_data, type_file_address) 147 | 148 | # # ProtocolConformanceDescriptor -> ConformanceFlags 149 | conformance_flags = read_little_endian_signed_integer(@binary_data, type_file_address + 12) 150 | kind = (conformance_flags & TYPE_METADATA_KIND_MASK) >> TYPE_METADATA_KIND_SHIFT 151 | 152 | next unless kind == 0 153 | 154 | indirect_relative_offset = get_indirect_relative_offset(type_file_address, protocol_descriptor) 155 | 156 | bound_symbol = bound_symbols.find { |symbol| symbol.address == indirect_relative_offset } 157 | if bound_symbol 158 | return true if search_symbols.include?(bound_symbol.symbol) 159 | elsif use_chained_fixups 160 | descriptor_offset = protocol_descriptor & ~1 161 | jump_ptr = type_file_address + descriptor_offset 162 | 163 | address = @binary_data[jump_ptr, 4].unpack1('I<') 164 | symbol_name = imported_symbols[address] 165 | return true if search_symbols.include?(symbol_name) 166 | end 167 | end 168 | false 169 | end 170 | 171 | private 172 | 173 | def read_next_symbol(binary_data, current_address, end_address, current_symbol) 174 | while current_address < end_address 175 | first_byte = read_byte(binary_data, current_address) 176 | current_address += 1 177 | immediate = first_byte & BIND_IMMEDIATE_MASK 178 | opcode = first_byte & BIND_OPCODE_MASK 179 | 180 | case opcode 181 | when BIND_OPCODE_DONE 182 | result = current_symbol.dup 183 | current_symbol.segment_offset = 0 184 | current_symbol.library = 0 185 | current_symbol.offset = 0 186 | current_symbol.symbol = '' 187 | return [result], current_address, current_symbol 188 | when BIND_OPCODE_SET_DYLIB_ORDINAL_IMM 189 | current_symbol.library = [immediate].pack('L').unpack1('L') 190 | when BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM 191 | current_symbol.symbol = read_null_terminated_string(binary_data[current_address..]) 192 | # Increase current pointer 193 | current_address += current_symbol.symbol.size + 1 194 | when BIND_OPCODE_ADD_ADDR_ULEB 195 | offset, new_current_address = read_uleb(@binary_data, current_address) 196 | current_symbol.offset = (current_symbol.offset + offset) & UINT64_MAX_VALUE 197 | current_address = new_current_address 198 | when BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB 199 | offset, new_current_address = read_uleb(@binary_data, current_address) 200 | current_symbol.offset = (current_symbol.offset + offset + UINT64_SIZE) & UINT64_MAX_VALUE 201 | current_address = new_current_address 202 | return [current_symbol], current_address, current_symbol 203 | when BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB 204 | offset, current_address = read_uleb(@binary_data, current_address) 205 | current_symbol.segment_offset = immediate 206 | current_symbol.offset = offset 207 | when BIND_OPCODE_SET_ADDEND_SLEB 208 | _, current_address = read_uleb(@binary_data, current_address) 209 | when BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED 210 | result = current_symbol.dup 211 | current_symbol.offset = ( 212 | current_symbol.offset + (immediate * UINT64_SIZE) + UINT64_SIZE 213 | ) & UINT64_MAX_VALUE 214 | return [result], current_address, current_symbol 215 | when BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB 216 | count, current_address = read_uleb(@binary_data, current_address) 217 | skipping, current_address = read_uleb(@binary_data, current_address) 218 | 219 | results = [] 220 | count.times do 221 | results << current_symbol.dup 222 | current_symbol.offset = (current_symbol.offset + skipping + UINT64_SIZE) & UINT64_MAX_VALUE 223 | end 224 | 225 | return results, current_address, current_symbol 226 | when BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB 227 | count, current_address = read_uleb(@binary_data, current_address) 228 | current_symbol.library = count 229 | when BIND_OPCODE_DO_BIND 230 | result = current_symbol.dup 231 | current_symbol.offset = (current_symbol.offset + UINT64_SIZE) & UINT64_MAX_VALUE 232 | return [result], current_address, current_symbol 233 | end 234 | end 235 | [[], current_address, current_symbol] 236 | end 237 | 238 | def read_byte(binary_data, address) 239 | binary_data[address, 1].unpack1('C') 240 | end 241 | 242 | def read_little_endian_signed_integer(binary_data, address) 243 | binary_data[address, 4].unpack1('l<') 244 | end 245 | 246 | def read_uleb(binary_data, address) 247 | next_byte = 0 248 | size = 0 249 | result = 0 250 | 251 | loop do 252 | next_byte = read_byte(binary_data, address) 253 | address += 1 254 | bytes = next_byte & 0x7F 255 | shifted = bytes << (size * 7) 256 | 257 | size += 1 258 | result |= shifted 259 | break if next_byte.nobits?(0x80) 260 | end 261 | 262 | [result, address] 263 | end 264 | 265 | def read_null_terminated_string(data) 266 | data.unpack1('Z*') 267 | end 268 | 269 | def vm_address(file_offset, macho) 270 | load_commands = macho.load_commands.select { |lc| lc.type == :LC_SEGMENT_64 || lc.type == :LC_SEGMENT } # rubocop:disable Naming/VariableNumber 271 | load_commands.each do |lc| 272 | next unless file_offset >= lc.fileoff && file_offset < (lc.fileoff + lc.filesize) 273 | unless lc.respond_to?(:sections) 274 | Logger.error 'Load command does not support sections function' 275 | next 276 | end 277 | 278 | lc.sections.each do |section| 279 | if file_offset >= section.offset && file_offset < (section.offset) + section.size 280 | return section.addr + (file_offset - section.offset) 281 | end 282 | end 283 | end 284 | nil 285 | end 286 | 287 | def parse_list(bytes, start, size) 288 | data_pointer = bytes[start..] 289 | file_offset = start 290 | pointer_size = 4 291 | class_pointers = [] 292 | 293 | (size / pointer_size).to_i.times do 294 | pointer = data_pointer.unpack1('l<') 295 | class_pointers << [pointer, file_offset] 296 | data_pointer = data_pointer[pointer_size..] 297 | file_offset += pointer_size 298 | end 299 | 300 | class_pointers 301 | end 302 | 303 | def get_indirect_relative_offset(type_file_address, protocol_descriptor) 304 | vm_start = vm_address(type_file_address, @macho_file) 305 | return nil if vm_start.nil? 306 | if (vm_start + protocol_descriptor).odd? 307 | (vm_start + protocol_descriptor) & ~1 308 | elsif vm_start + protocol_descriptor > 0 309 | vm_start + protocol_descriptor 310 | end 311 | end 312 | end 313 | 314 | class BoundSymbol 315 | attr_accessor :segment_offset, :library, :offset, :symbol, :address 316 | 317 | def initialize(segment_offset:, library:, offset:, symbol:) 318 | @segment_offset = segment_offset 319 | @library = library 320 | @offset = offset 321 | @symbol = symbol 322 | @address = 0 323 | end 324 | end 325 | end 326 | -------------------------------------------------------------------------------- /lib/utils/network.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'json' 3 | require 'uri' 4 | require 'async/http/internet/instance' 5 | 6 | module EmergeCLI 7 | class Network 8 | EMERGE_API_PROD_URL = 'api.emergetools.com'.freeze 9 | public_constant :EMERGE_API_PROD_URL 10 | 11 | RETRY_DELAY = 5 12 | MAX_RETRIES = 3 13 | 14 | def initialize(api_token: nil, base_url: EMERGE_API_PROD_URL) 15 | @base_url = base_url 16 | @api_token = api_token 17 | @internet = Async::HTTP::Internet.new 18 | end 19 | 20 | def get(path:, headers: {}, query: nil, max_retries: MAX_RETRIES) 21 | request(:get, path, nil, headers, query, max_retries) 22 | end 23 | 24 | def post(path:, body:, headers: {}, query: nil, max_retries: MAX_RETRIES) 25 | request(:post, path, body, headers, query, max_retries) 26 | end 27 | 28 | def put(path:, body:, headers: {}, max_retries: MAX_RETRIES) 29 | request(:put, path, body, headers, nil, max_retries) 30 | end 31 | 32 | def close 33 | @internet.close 34 | end 35 | 36 | private 37 | 38 | def request(method, path, body, custom_headers, query = nil, max_retries = MAX_RETRIES) 39 | uri = if path.start_with?('http') 40 | URI.parse(path) 41 | else 42 | query_string = query ? URI.encode_www_form(query) : nil 43 | URI::HTTPS.build( 44 | host: @base_url, 45 | path: path, 46 | query: query_string 47 | ) 48 | end 49 | absolute_uri = uri.to_s 50 | 51 | headers = { 52 | 'User-Agent' => "emerge-cli/#{EmergeCLI::VERSION}" 53 | } 54 | headers['X-API-Token'] = @api_token if @api_token 55 | headers['Content-Type'] = 'application/json' if method == :post && body.is_a?(Hash) 56 | headers.merge!(custom_headers) 57 | 58 | body = JSON.dump(body) if body.is_a?(Hash) && method == :post 59 | 60 | Logger.debug "Request: #{method} #{truncate_uri(absolute_uri)} #{method == :post ? body : 'N/A'}" 61 | 62 | retries = 0 63 | begin 64 | response = perform_request(method, absolute_uri, headers, body) 65 | 66 | unless response.success? 67 | Logger.error "Request failed: #{absolute_uri} #{response}" 68 | raise "Request failed: #{absolute_uri} #{response}" 69 | end 70 | 71 | response 72 | rescue StandardError => e 73 | retries += 1 74 | if retries <= max_retries 75 | delay = RETRY_DELAY * retries 76 | error_message = e.message 77 | Logger.warn "Request failed (attempt #{retries}/#{max_retries}): #{error_message}" 78 | Logger.warn "Retrying in #{delay} seconds..." 79 | 80 | begin 81 | @internet.close 82 | rescue StandardError 83 | nil 84 | end 85 | @internet = Async::HTTP::Internet.new 86 | 87 | sleep delay 88 | retry 89 | else 90 | unless max_retries == 0 91 | Logger.error "Request failed after #{max_retries} attempts: #{absolute_uri} #{e.message}" 92 | end 93 | raise e 94 | end 95 | end 96 | end 97 | 98 | def perform_request(method, absolute_uri, headers, body) 99 | headers ||= {} 100 | 101 | case method 102 | when :get 103 | @internet.get(absolute_uri, headers:) 104 | when :post 105 | @internet.post(absolute_uri, headers:, body:) 106 | when :put 107 | @internet.put(absolute_uri, headers:, body:) 108 | else 109 | raise "Unsupported method: #{method}" 110 | end 111 | end 112 | 113 | def truncate_uri(uri, max_length = 100) 114 | uri.length > max_length ? "#{uri[0..max_length]}..." : uri 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/utils/profiler.rb: -------------------------------------------------------------------------------- 1 | module EmergeCLI 2 | # NOTE: This class is not thread-safe. 3 | class Profiler 4 | def initialize(enabled: false) 5 | @enabled = enabled 6 | @measurements = {} 7 | @start_times = {} 8 | end 9 | 10 | def measure(label) 11 | return yield unless @enabled 12 | 13 | start(label) 14 | result = yield 15 | stop(label) 16 | result 17 | end 18 | 19 | def start(label) 20 | return unless @enabled 21 | @start_times[label] = Time.now 22 | end 23 | 24 | def stop(label) 25 | return unless @enabled 26 | return unless @start_times[label] 27 | 28 | duration = Time.now - @start_times[label] 29 | @measurements[label] ||= { count: 0, total_time: 0 } 30 | @measurements[label][:count] += 1 31 | @measurements[label][:total_time] += duration 32 | end 33 | 34 | def report 35 | return unless @enabled 36 | 37 | Logger.info '=== Performance Profile ===' 38 | @measurements.sort_by { |_, v| -v[:total_time] }.each do |label, data| 39 | avg_time = data[:total_time] / data[:count] 40 | Logger.info sprintf('%-