DYXCFrameworkBuilder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DYXCFrameworkBuilder
4
+ class PodspecParser
5
+ attr_reader :name, :version, :deployment_target, :podspec_path, :podspec_dir
6
+
7
+ def initialize(podspec_path)
8
+ @podspec_path = File.expand_path(podspec_path)
9
+ @podspec_dir = File.dirname(@podspec_path)
10
+
11
+ unless File.exist?(@podspec_path)
12
+ raise DYXCFrameworkBuilder::Error, "Podspec file not found: #{@podspec_path}"
13
+ end
14
+
15
+ parse_podspec
16
+ end
17
+
18
+ def find_workspace_path
19
+ example_dir = File.join(@podspec_dir, 'Example')
20
+
21
+ if Dir.exist?(example_dir)
22
+ # 查找 .xcworkspace 文件
23
+ workspace_files = Dir.glob(File.join(example_dir, '*.xcworkspace'))
24
+ return workspace_files.first if workspace_files.any?
25
+
26
+ # 如果没有找到 .xcworkspace,查找 .xcodeproj
27
+ project_files = Dir.glob(File.join(example_dir, '*.xcodeproj'))
28
+ return project_files.first if project_files.any?
29
+ end
30
+
31
+ raise DYXCFrameworkBuilder::Error, "No workspace or project file found in #{example_dir}"
32
+ end
33
+
34
+ def output_directory
35
+ File.join(@podspec_dir, 'build')
36
+ end
37
+
38
+ def framework_name
39
+ "#{@name}.xcframework"
40
+ end
41
+
42
+ def to_yaml_config
43
+ {
44
+ 'project' => {
45
+ 'path' => find_workspace_path,
46
+ 'scheme' => @name,
47
+ 'target' => @name,
48
+ 'configuration' => 'Release'
49
+ },
50
+ 'output' => {
51
+ 'directory' => output_directory,
52
+ 'framework_name' => framework_name
53
+ },
54
+ 'platforms' => {
55
+ 'ios' => {
56
+ 'deployment_target' => @deployment_target || '11.0',
57
+ 'architectures' => ['arm64']
58
+ },
59
+ 'ios_simulator' => {
60
+ 'deployment_target' => @deployment_target || '11.0',
61
+ 'architectures' => ['arm64', 'x86_64']
62
+ }
63
+ },
64
+ 'build_settings' => {
65
+ 'enable_bitcode' => false,
66
+ 'skip_warnings' => true,
67
+ 'swift_version' => '5.0'
68
+ },
69
+ 'metadata' => {
70
+ 'generated_from_podspec' => @podspec_path,
71
+ 'podspec_version' => @version,
72
+ 'generated_at' => Time.now.strftime('%Y-%m-%d %H:%M:%S')
73
+ }
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ def parse_podspec
80
+ # 读取 podspec 文件内容
81
+ content = File.read(@podspec_path)
82
+
83
+ # 解析 name
84
+ if match = content.match(/s\.name\s*=\s*['"]([^'"]+)['"]/)
85
+ @name = match[1]
86
+ else
87
+ # 从文件名推断
88
+ @name = File.basename(@podspec_path, '.podspec')
89
+ end
90
+
91
+ # 解析 version
92
+ if match = content.match(/s\.version\s*=\s*['"]([^'"]+)['"]/)
93
+ @version = match[1]
94
+ end
95
+
96
+ # 解析 deployment_target
97
+ if match = content.match(/s\.ios\.deployment_target\s*=\s*['"]([^'"]+)['"]/)
98
+ @deployment_target = match[1]
99
+ elsif match = content.match(/s\.deployment_target\s*=\s*['"]([^'"]+)['"]/)
100
+ @deployment_target = match[1]
101
+ end
102
+
103
+ validate_parsed_data
104
+ end
105
+
106
+ def validate_parsed_data
107
+ raise DYXCFrameworkBuilder::Error, "Could not parse name from podspec: #{@podspec_path}" if @name.nil? || @name.empty?
108
+
109
+ puts "[INFO] Parsed podspec: #{@name} (#{@version || 'unknown version'})"
110
+ puts "[INFO] Deployment target: #{@deployment_target || 'using default 11.0'}"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DYXCFrameworkBuilder
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module DYXCFrameworkBuilder
6
+ # Core XCFramework building logic
7
+ class CoreBuilder
8
+ attr_reader :config, :verbose
9
+
10
+ def initialize(config, verbose: false)
11
+ @config = config
12
+ @verbose = verbose
13
+ end
14
+
15
+ def build
16
+ log "Building XCFramework from configuration"
17
+ log "Project: #{@config.project_path}"
18
+ log "Scheme: #{@config.scheme}"
19
+ log "Configuration: #{@config.configuration}"
20
+ log "Output Directory: #{@config.output_dir}"
21
+
22
+ # Create output directory
23
+ FileUtils.mkdir_p(@config.output_dir)
24
+
25
+ # Build for each platform
26
+ frameworks = []
27
+
28
+ if @config.platforms['ios']
29
+ log "Building for iOS device..."
30
+ ios_framework = build_for_platform('iphoneos', @config.platforms['ios'])
31
+ frameworks << ios_framework if ios_framework
32
+ end
33
+
34
+ if @config.platforms['ios_simulator']
35
+ log "Building for iOS Simulator..."
36
+ sim_framework = build_for_platform('iphonesimulator', @config.platforms['ios_simulator'])
37
+ frameworks << sim_framework if sim_framework
38
+ end
39
+
40
+ # Create XCFramework
41
+ if frameworks.any?
42
+ create_xcframework(frameworks)
43
+ else
44
+ raise Error, "No frameworks were built successfully"
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def build_for_platform(sdk, platform_config)
51
+ deployment_target = platform_config['deployment_target'] || '11.0'
52
+ architectures = filter_architectures_for_sdk(platform_config['architectures'] || ['arm64'], sdk)
53
+
54
+ log "Building for #{sdk} with architectures: #{architectures.join(', ')}"
55
+
56
+ # Create derived data path
57
+ derived_data_path = File.join(@config.output_dir, "DerivedData")
58
+
59
+ # For modern Xcode, we can build all architectures in one go instead of separate archives
60
+ if architectures.length == 1 || should_build_universal_archive(sdk)
61
+ # Build single archive with multiple architectures
62
+ archive_path = File.join(@config.output_dir, "#{@config.scheme}-#{sdk}.xcarchive")
63
+
64
+ build_cmd = build_archive_command(sdk, architectures, deployment_target, archive_path, derived_data_path)
65
+
66
+ log "Executing: #{build_cmd}" if @verbose
67
+
68
+ success = execute_command(build_cmd)
69
+
70
+ if success
71
+ log "✅ Successfully built archive for #{sdk} with architectures: #{architectures.join(', ')}"
72
+ framework_path = create_framework_from_archives([archive_path], sdk)
73
+ return framework_path
74
+ else
75
+ log "❌ Failed to build archive for #{sdk}"
76
+ end
77
+ else
78
+ # Fallback: Build separate archives for each architecture
79
+ archive_paths = []
80
+
81
+ architectures.each do |arch|
82
+ archive_path = File.join(@config.output_dir, "#{@config.scheme}-#{sdk}-#{arch}.xcarchive")
83
+
84
+ build_cmd = build_archive_command(sdk, [arch], deployment_target, archive_path, derived_data_path)
85
+
86
+ log "Executing: #{build_cmd}" if @verbose
87
+
88
+ success = execute_command(build_cmd)
89
+
90
+ if success
91
+ archive_paths << archive_path
92
+ log "✅ Successfully built archive for #{sdk} #{arch}"
93
+ else
94
+ log "❌ Failed to build archive for #{sdk} #{arch}"
95
+ end
96
+ end
97
+
98
+ if archive_paths.any?
99
+ # Create framework from archives
100
+ framework_path = create_framework_from_archives(archive_paths, sdk)
101
+ return framework_path
102
+ end
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ def build_archive_command(sdk, architectures, deployment_target, archive_path, derived_data_path)
109
+ # Determine whether to use -workspace or -project based on file extension
110
+ project_flag, project_path = determine_project_type(@config.project_path)
111
+
112
+ # Ensure architectures is an array
113
+ arch_list = Array(architectures)
114
+
115
+ cmd = [
116
+ "xcodebuild",
117
+ "archive",
118
+ project_flag, "\"#{project_path}\"",
119
+ "-scheme", "\"#{@config.scheme}\"",
120
+ "-configuration", @config.configuration,
121
+ "-destination", "\"generic/platform=#{platform_destination(sdk)}\"",
122
+ "-archivePath", "\"#{archive_path}\"",
123
+ "-derivedDataPath", "\"#{derived_data_path}\"",
124
+ "SKIP_INSTALL=NO",
125
+ "BUILD_LIBRARY_FOR_DISTRIBUTION=YES"
126
+ ]
127
+
128
+ # Add target if specified and different from scheme
129
+ if @config.target && @config.target != @config.scheme
130
+ cmd += ["-target", "\"#{@config.target}\""]
131
+ end
132
+
133
+ # Add architectures if multiple specified
134
+ if arch_list.length > 1
135
+ cmd << "ARCHS=#{arch_list.join(' ')}"
136
+ cmd << "VALID_ARCHS=#{arch_list.join(' ')}"
137
+ elsif arch_list.length == 1
138
+ cmd << "ARCHS=#{arch_list.first}"
139
+ cmd << "VALID_ARCHS=#{arch_list.first}"
140
+ end
141
+
142
+ # Add deployment target
143
+ case sdk
144
+ when 'iphoneos', 'iphonesimulator'
145
+ cmd << "IPHONEOS_DEPLOYMENT_TARGET=#{deployment_target}"
146
+ end
147
+
148
+ # Skip warnings if configured
149
+ if @config.skip_warnings
150
+ cmd << "GCC_WARN_INHIBIT_ALL_WARNINGS=YES"
151
+ cmd << "SWIFT_SUPPRESS_WARNINGS=YES"
152
+ cmd << "CLANG_WARN_DOCUMENTATION_COMMENTS=NO"
153
+ cmd << "CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER=NO"
154
+ end
155
+
156
+ # Disable bitcode if configured
157
+ unless @config.enable_bitcode
158
+ cmd << "ENABLE_BITCODE=NO"
159
+ end
160
+
161
+ # Additional build settings for better compatibility
162
+ cmd << "ONLY_ACTIVE_ARCH=NO"
163
+ cmd << "DEFINES_MODULE=YES"
164
+
165
+ cmd.join(" ")
166
+ end
167
+
168
+ def platform_destination(sdk)
169
+ case sdk
170
+ when 'iphoneos'
171
+ 'iOS'
172
+ when 'iphonesimulator'
173
+ 'iOS Simulator'
174
+ else
175
+ sdk
176
+ end
177
+ end
178
+
179
+ def determine_project_type(project_path)
180
+ case File.extname(project_path).downcase
181
+ when '.xcworkspace'
182
+ ['-workspace', project_path]
183
+ when '.xcodeproj'
184
+ ['-project', project_path]
185
+ else
186
+ # Default to workspace if no extension or unknown extension
187
+ # Most modern iOS projects use workspaces
188
+ if File.exist?("#{project_path}.xcworkspace")
189
+ ['-workspace', "#{project_path}.xcworkspace"]
190
+ elsif File.exist?("#{project_path}.xcodeproj")
191
+ ['-project', "#{project_path}.xcodeproj"]
192
+ else
193
+ # Assume workspace as default for modern projects
194
+ ['-workspace', project_path]
195
+ end
196
+ end
197
+ end
198
+
199
+ def filter_architectures_for_sdk(architectures, sdk)
200
+ case sdk
201
+ when 'iphoneos'
202
+ # iOS device: only arm64
203
+ architectures.select { |arch| ['arm64'].include?(arch) }
204
+ when 'iphonesimulator'
205
+ # iOS simulator: arm64 and x86_64
206
+ architectures.select { |arch| ['arm64', 'x86_64'].include?(arch) }
207
+ else
208
+ architectures
209
+ end
210
+ end
211
+
212
+ def should_build_universal_archive(sdk)
213
+ # Modern Xcode can build universal archives in one go for most cases
214
+ true
215
+ end
216
+
217
+ def fix_framework_binary_name(framework_path, sdk)
218
+ # Keep the original scheme name for the binary (no SDK suffix)
219
+ target_binary_path = File.join(framework_path, @config.scheme)
220
+
221
+ # Check if binary already exists with correct name
222
+ if File.exist?(target_binary_path)
223
+ log "✅ Framework binary verified: #{target_binary_path}" if @verbose
224
+ else
225
+ # Try to find any binary file in the framework and rename it
226
+ found_binary = false
227
+ Dir.glob(File.join(framework_path, "*")).each do |file|
228
+ next if File.directory?(file)
229
+ next if File.extname(file) != "" # Skip files with extensions
230
+ next if file.include?('Info.plist')
231
+
232
+ # Check if it's likely a binary file
233
+ if File.executable?(file) || File.size(file) > 1024 # Assume binary if executable or large enough
234
+ log "Renaming binary from #{File.basename(file)} to #{@config.scheme}" if @verbose
235
+ FileUtils.mv(file, target_binary_path)
236
+ found_binary = true
237
+ break
238
+ end
239
+ end
240
+
241
+ unless found_binary
242
+ log "❌ Warning: No binary found in framework: #{framework_path}"
243
+ return nil
244
+ end
245
+ end
246
+
247
+ # Verify the binary file exists and has correct permissions
248
+ if File.exist?(target_binary_path)
249
+ FileUtils.chmod(0755, target_binary_path) unless File.executable?(target_binary_path)
250
+ log "✅ Framework binary ready: #{target_binary_path}" if @verbose
251
+ return target_binary_path
252
+ end
253
+
254
+ nil
255
+ end
256
+
257
+ def update_framework_references(framework_path, old_name, new_name)
258
+ # Update Info.plist if it exists
259
+ info_plist_path = File.join(framework_path, 'Info.plist')
260
+ if File.exist?(info_plist_path)
261
+ content = File.read(info_plist_path)
262
+ updated_content = content.gsub(old_name, new_name)
263
+ File.write(info_plist_path, updated_content) if content != updated_content
264
+ end
265
+
266
+ # Update module.modulemap if it exists
267
+ modulemap_paths = [
268
+ File.join(framework_path, 'Modules', 'module.modulemap'),
269
+ File.join(framework_path, 'Headers', 'module.modulemap')
270
+ ]
271
+
272
+ modulemap_paths.each do |modulemap_path|
273
+ if File.exist?(modulemap_path)
274
+ content = File.read(modulemap_path)
275
+ updated_content = content.gsub(/framework module #{old_name}/, "framework module #{new_name}")
276
+ File.write(modulemap_path, updated_content) if content != updated_content
277
+ end
278
+ end
279
+ end
280
+
281
+ def create_framework_from_archives(archive_paths, sdk)
282
+ log "Creating framework from #{archive_paths.length} archive(s) for #{sdk}"
283
+
284
+ # Use temporary SDK-specific name during build, will be renamed for XCFramework
285
+ temp_framework_path = File.join(@config.output_dir, "#{@config.scheme}-#{sdk}.framework")
286
+
287
+ # Remove existing framework if it exists
288
+ FileUtils.rm_rf(temp_framework_path) if File.exist?(temp_framework_path)
289
+
290
+ if archive_paths.length == 1
291
+ # Single architecture - just copy the framework
292
+ archive_framework_path = find_framework_in_archive(archive_paths.first)
293
+ if archive_framework_path && File.exist?(archive_framework_path)
294
+ FileUtils.cp_r(archive_framework_path, temp_framework_path)
295
+
296
+ # Rename to final framework name
297
+ final_framework_path = File.join(@config.output_dir, "#{@config.scheme}.framework")
298
+ FileUtils.rm_rf(final_framework_path) if File.exist?(final_framework_path)
299
+ FileUtils.mv(temp_framework_path, final_framework_path)
300
+
301
+ # Fix binary name to use scheme name
302
+ fix_framework_binary_name(final_framework_path, sdk)
303
+
304
+ log "✅ Created framework: #{final_framework_path}"
305
+ return final_framework_path
306
+ end
307
+ else
308
+ # Multiple architectures - create universal binary
309
+ log "Creating universal framework for #{sdk}..."
310
+ universal_framework_path = create_universal_framework(archive_paths, sdk)
311
+ return universal_framework_path if universal_framework_path
312
+ end
313
+
314
+ log "⚠️ Warning: Could not find framework in archives for #{sdk}"
315
+ nil
316
+ end
317
+
318
+ def find_framework_in_archive(archive_path)
319
+ # Try different possible paths for the framework in the archive
320
+ possible_paths = [
321
+ File.join(archive_path, "Products/Library/Frameworks/#{@config.scheme}.framework"),
322
+ File.join(archive_path, "Products/@rpath/#{@config.scheme}.framework"),
323
+ File.join(archive_path, "Products/usr/local/lib/#{@config.scheme}.framework")
324
+ ]
325
+
326
+ possible_paths.each do |path|
327
+ return path if File.exist?(path)
328
+ end
329
+
330
+ # If not found, search recursively
331
+ framework_name = "#{@config.scheme}.framework"
332
+ Dir.glob(File.join(archive_path, "**/#{framework_name}")).first
333
+ end
334
+
335
+ def create_universal_framework(archive_paths, sdk)
336
+ log "Creating universal binary for multiple architectures"
337
+
338
+ # Use temporary SDK-specific name during build
339
+ temp_framework_path = File.join(@config.output_dir, "#{@config.scheme}-#{sdk}.framework")
340
+ temp_frameworks = []
341
+ binary_architectures = []
342
+
343
+ # Extract all frameworks and check architectures
344
+ archive_paths.each_with_index do |archive_path, index|
345
+ archive_framework_path = find_framework_in_archive(archive_path)
346
+ if archive_framework_path && File.exist?(archive_framework_path)
347
+ temp_framework_path = File.join(@config.output_dir, "temp_#{sdk}_#{index}.framework")
348
+ FileUtils.cp_r(archive_framework_path, temp_framework_path)
349
+
350
+ # Check binary architecture
351
+ binary_path = File.join(temp_framework_path, @config.scheme)
352
+ if File.exist?(binary_path)
353
+ arch_info = `lipo -info "#{binary_path}" 2>/dev/null`.strip
354
+ log "Framework #{index} architecture: #{arch_info}" if @verbose
355
+
356
+ # Extract architecture from lipo output
357
+ if arch_info.include?(":")
358
+ arch = arch_info.split(":").last.strip
359
+ unless binary_architectures.include?(arch)
360
+ binary_architectures << arch
361
+ temp_frameworks << temp_framework_path
362
+ else
363
+ log "⚠️ Skipping duplicate architecture: #{arch}"
364
+ FileUtils.rm_rf(temp_framework_path)
365
+ end
366
+ else
367
+ temp_frameworks << temp_framework_path
368
+ end
369
+ else
370
+ temp_frameworks << temp_framework_path
371
+ end
372
+ end
373
+ end
374
+
375
+ if temp_frameworks.empty?
376
+ log "❌ No frameworks found in archives"
377
+ return nil
378
+ end
379
+
380
+ # Use the first framework as base
381
+ base_framework = temp_frameworks.first
382
+ FileUtils.cp_r(base_framework, temp_framework_path)
383
+
384
+ # Create universal binary if we have multiple frameworks with different architectures
385
+ if temp_frameworks.length > 1
386
+ # Use the original scheme name for the binary
387
+ binary_path = File.join(temp_framework_path, @config.scheme)
388
+ all_binaries = temp_frameworks.map { |fw| File.join(fw, @config.scheme) }.select { |b| File.exist?(b) }
389
+
390
+ if all_binaries.length > 1
391
+ # Use lipo to create universal binary
392
+ lipo_cmd = "lipo -create #{all_binaries.map { |b| "\"#{b}\"" }.join(' ')} -output \"#{binary_path}\""
393
+
394
+ log "Creating universal binary: #{lipo_cmd}" if @verbose
395
+
396
+ if execute_command(lipo_cmd)
397
+ log "✅ Successfully created universal framework with architectures: #{binary_architectures.join(', ')}"
398
+ else
399
+ log "⚠️ Warning: Failed to create universal binary, using single architecture"
400
+ end
401
+ else
402
+ log "✅ Using single architecture framework"
403
+ end
404
+ else
405
+ log "✅ Using single framework (only one valid architecture)"
406
+ end
407
+
408
+ # Clean up temporary frameworks
409
+ temp_frameworks.each { |fw| FileUtils.rm_rf(fw) }
410
+
411
+ # Rename to final framework name
412
+ final_framework_path = File.join(@config.output_dir, "#{@config.scheme}.framework")
413
+ FileUtils.rm_rf(final_framework_path) if File.exist?(final_framework_path)
414
+ FileUtils.mv(temp_framework_path, final_framework_path)
415
+
416
+ # Fix binary name to use scheme name
417
+ fix_framework_binary_name(final_framework_path, sdk)
418
+
419
+ final_framework_path
420
+ end
421
+
422
+ def create_xcframework(frameworks)
423
+ xcframework_path = File.join(@config.output_dir, @config.framework_name)
424
+
425
+ log "Creating XCFramework at: #{xcframework_path}"
426
+
427
+ # Remove existing xcframework if it exists
428
+ FileUtils.rm_rf(xcframework_path) if File.exist?(xcframework_path)
429
+
430
+ cmd = [
431
+ "xcodebuild",
432
+ "-create-xcframework"
433
+ ]
434
+
435
+ frameworks.each do |framework|
436
+ cmd << "-framework" << "\"#{framework}\""
437
+ end
438
+
439
+ cmd << "-output" << "\"#{xcframework_path}\""
440
+
441
+ build_cmd = cmd.join(" ")
442
+ log "Executing: #{build_cmd}" if @verbose
443
+
444
+ success = execute_command(build_cmd)
445
+
446
+ if success
447
+ # Verify the XCFramework was created
448
+ if File.exist?(xcframework_path)
449
+ log "🎉 Successfully created XCFramework: #{xcframework_path}"
450
+
451
+ # Show XCFramework info
452
+ info_cmd = "xcodebuild -checkFirstLaunchForXcode"
453
+ execute_command("#{info_cmd} > /dev/null 2>&1") # Suppress output
454
+
455
+ # Clean up intermediate frameworks
456
+ frameworks.each { |fw| FileUtils.rm_rf(fw) }
457
+
458
+ return xcframework_path
459
+ else
460
+ raise Error, "XCFramework was not created at expected path: #{xcframework_path}"
461
+ end
462
+ else
463
+ raise Error, "Failed to create XCFramework"
464
+ end
465
+ end
466
+
467
+ def execute_command(command)
468
+ if @verbose
469
+ system(command)
470
+ else
471
+ system("#{command} > /dev/null 2>&1")
472
+ end
473
+ end
474
+
475
+ def log(message)
476
+ if @verbose
477
+ puts "[INFO] #{message}"
478
+ else
479
+ puts message
480
+ end
481
+ end
482
+ end
483
+ end