ioscmpr 0.1.1
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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +28 -0
- data/README.md +19 -0
- data/Rakefile +4 -0
- data/bin/ioscmpr +17 -0
- data/ioscmpr.gemspec +34 -0
- data/lib/ioscmpr.rb +10 -0
- data/lib/ioscmpr/command.rb +21 -0
- data/lib/ioscmpr/command/bloaty +0 -0
- data/lib/ioscmpr/command/car.rb +164 -0
- data/lib/ioscmpr/command/ipa.rb +122 -0
- data/lib/ioscmpr/command/linkmap.rb +348 -0
- data/lib/ioscmpr/command/macho.rb +161 -0
- data/lib/ioscmpr/size_diff.rb +132 -0
- data/lib/ioscmpr/version.rb +5 -0
- metadata +129 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7dcaff4f48afc96701e9e1e885e764f99aff5cb283d4059e4b44a3f31420757e
|
4
|
+
data.tar.gz: 53bcbcad67e62b03e4cb4fee995120bf1aa2c44df52a058058a7c85a1e8cd48e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: aa85d316e9a00dbdd2163c061bb61d833e6c5b9dde6364b197ad44d526576d4e31d42caba5ca8ee49ad7caff51733d256b2a1c779b5d180f55ba11ecc2373805
|
7
|
+
data.tar.gz: '0885a5c2de27d7d592e7cc765788ec7ded69945d908572c4bf1eb03b0b7a96a3c05cc784707275f81f6120c2e3d0f99166263853c32845ce88ca24aa60c852e3'
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
ioscmpr (0.1.1)
|
5
|
+
claide
|
6
|
+
colorize
|
7
|
+
terminal-table
|
8
|
+
|
9
|
+
GEM
|
10
|
+
remote: https://rubygems.org/
|
11
|
+
specs:
|
12
|
+
claide (1.0.3)
|
13
|
+
colorize (0.8.1)
|
14
|
+
rake (10.5.0)
|
15
|
+
terminal-table (1.8.0)
|
16
|
+
unicode-display_width (~> 1.1, >= 1.1.1)
|
17
|
+
unicode-display_width (1.6.0)
|
18
|
+
|
19
|
+
PLATFORMS
|
20
|
+
ruby
|
21
|
+
|
22
|
+
DEPENDENCIES
|
23
|
+
bundler (~> 1.17)
|
24
|
+
ioscmpr!
|
25
|
+
rake (~> 10.0)
|
26
|
+
|
27
|
+
BUNDLED WITH
|
28
|
+
1.17.3
|
data/README.md
ADDED
data/Rakefile
ADDED
data/bin/ioscmpr
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'ioscmpr'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
# require "irb"
|
15
|
+
# IRB.start(__FILE__)
|
16
|
+
|
17
|
+
Ioscmpr::Command.run(ARGV)
|
data/ioscmpr.gemspec
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'ioscmpr/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'ioscmpr'
|
9
|
+
spec.version = Ioscmpr::VERSION
|
10
|
+
spec.authors = ['songruiwang']
|
11
|
+
spec.email = ['songruiwang@kuaishou.com']
|
12
|
+
|
13
|
+
spec.summary = 'Write a short summary, because RubyGems requires one.'
|
14
|
+
spec.description = 'Write a longer description or delete this line.'
|
15
|
+
spec.homepage = 'https://github.com/tripleCC/ioscmpr'
|
16
|
+
|
17
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
18
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
19
|
+
|
20
|
+
# Specify which files should be added to the gem when it is released.
|
21
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
22
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
23
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
24
|
+
end
|
25
|
+
spec.bindir = 'bin'
|
26
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
27
|
+
spec.require_paths = ['lib']
|
28
|
+
|
29
|
+
spec.add_dependency 'claide'
|
30
|
+
spec.add_dependency 'colorize'
|
31
|
+
spec.add_dependency 'terminal-table'
|
32
|
+
spec.add_development_dependency 'bundler', '~> 1.17'
|
33
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
34
|
+
end
|
data/lib/ioscmpr.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'claide'
|
4
|
+
|
5
|
+
module Ioscmpr
|
6
|
+
class Command < CLAide::Command
|
7
|
+
require 'ioscmpr/command/car'
|
8
|
+
require 'ioscmpr/command/ipa'
|
9
|
+
require 'ioscmpr/command/macho'
|
10
|
+
require 'ioscmpr/command/linkmap'
|
11
|
+
|
12
|
+
self.abstract_command = true
|
13
|
+
self.command = 'ioscmpr'
|
14
|
+
|
15
|
+
self.description = 'ipa 包瘦身命令行辅助工具.'
|
16
|
+
|
17
|
+
def self.options
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
Binary file
|
@@ -0,0 +1,164 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'ioscmpr/size_diff'
|
6
|
+
|
7
|
+
module Ioscmpr
|
8
|
+
class Command
|
9
|
+
class Car < Command
|
10
|
+
self.summary = '对比 car'
|
11
|
+
self.description = '对比 car 中各文件差异.'
|
12
|
+
|
13
|
+
self.arguments = [
|
14
|
+
CLAide::Argument.new('PATHS', true, true)
|
15
|
+
]
|
16
|
+
|
17
|
+
def self.options
|
18
|
+
[
|
19
|
+
['--visible-same', '显示大小一致的对比'],
|
20
|
+
['--with-compression', '展示使用不同压缩算法的数据'],
|
21
|
+
['--with-thin', '瘦身后进行对比, 以 iPhoneX, 8.0 为基准 (设备和支持最低系统版本不一样, 瘦身参数也不一, 可以通过 Adhoc 的 App thining 生成的 Packaging.log 查看实际参数)']
|
22
|
+
].concat(super)
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(argv)
|
26
|
+
@paths = argv.arguments!
|
27
|
+
@visible_same = argv.flag?('visible-same')
|
28
|
+
@with_compression = argv.flag?('with-compression')
|
29
|
+
@with_thin = argv.flag?('with-thin')
|
30
|
+
super
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate!
|
34
|
+
files = @paths.reject { |path| File.file?(path) }
|
35
|
+
help! '必须传入至少一个 car 文件路径' unless @paths.any?
|
36
|
+
help! "#{files} 需要为 car 压缩文件" unless files.empty?
|
37
|
+
end
|
38
|
+
|
39
|
+
def run
|
40
|
+
prepare
|
41
|
+
|
42
|
+
@cars = @paths.map do |path|
|
43
|
+
output = `xcrun --sdk iphoneos assetutil --info '#{path}'`
|
44
|
+
car = CarEntity.new(JSON.parse(output.scrub))
|
45
|
+
car
|
46
|
+
end
|
47
|
+
|
48
|
+
clean
|
49
|
+
|
50
|
+
key = @with_compression ? :unique_name : :name
|
51
|
+
names = @cars.map(&:image_sets).flatten.sort_by(&:size).map(&key)
|
52
|
+
items_accessor = lambda do |group|
|
53
|
+
group.image_sets.each_with_object({}) do |i, hash|
|
54
|
+
hash[i.send(key)] = i
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
options = {
|
59
|
+
filter_zero_gap_sum: !@visible_same,
|
60
|
+
items_accessor: items_accessor,
|
61
|
+
item_finder: ->(items, name) { items[name] }
|
62
|
+
}
|
63
|
+
diff = SizeDiff.new(@cars, names, options)
|
64
|
+
diff.run
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def work_dir
|
70
|
+
File.join(Dir.pwd, '.ioscmpr.car')
|
71
|
+
end
|
72
|
+
|
73
|
+
def prepare
|
74
|
+
if @with_thin
|
75
|
+
Dir.mkdir(work_dir) unless File.directory?(work_dir)
|
76
|
+
|
77
|
+
outputs = []
|
78
|
+
@paths.each_with_index do |path, index|
|
79
|
+
output = File.join(work_dir, "#{index}.car")
|
80
|
+
`xcrun --sdk iphoneos assetutil --idiom phone --subtype 570 --scale 3 --display-gamut srgb --graphicsclass MTL2,2 --graphicsclassfallbacks MTL1,2:GLES2,0 --memory 1 --hostedidioms car,watch '#{path}' -o '#{output}'`
|
81
|
+
outputs << output
|
82
|
+
end
|
83
|
+
@paths = outputs
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def clean
|
88
|
+
FileUtils.rm_rf(work_dir) if @with_thin && File.directory?(work_dir)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
module Ioscmpr
|
95
|
+
class CarEntity
|
96
|
+
attr_reader :image_sets
|
97
|
+
attr_reader :assets
|
98
|
+
attr_reader :compressions
|
99
|
+
attr_reader :size
|
100
|
+
|
101
|
+
def initialize(car)
|
102
|
+
@image_sets = car.group_by { |asset| asset['Name'] }.map do |name, assets|
|
103
|
+
assets = assets.map { |asset| CarEntity::Asset.new(asset) }
|
104
|
+
image_set = CarEntity::ImageSet.new(name, assets)
|
105
|
+
image_set
|
106
|
+
end
|
107
|
+
|
108
|
+
@assets = @image_sets.map(&:assets).flatten
|
109
|
+
@compressions = @image_sets.map(&:compressions).uniq
|
110
|
+
@size = @image_sets.map(&:size).compact.reduce(&:+)
|
111
|
+
end
|
112
|
+
|
113
|
+
class ImageSet
|
114
|
+
attr_reader :name
|
115
|
+
attr_reader :size
|
116
|
+
attr_reader :assets
|
117
|
+
|
118
|
+
def initialize(name, assets = [])
|
119
|
+
@name = name || ''
|
120
|
+
@assets = assets
|
121
|
+
@size = assets.map(&:size).compact.reduce(&:+) || 0
|
122
|
+
end
|
123
|
+
|
124
|
+
def unique_name
|
125
|
+
rendition_names = assets.map do |asset|
|
126
|
+
" #{asset.rendition_name}(#{asset.compression})"
|
127
|
+
end.join("\n")
|
128
|
+
"#{name}\n#{rendition_names}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def compressions
|
132
|
+
assets.map(&:compression).compact.uniq
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
class Asset
|
137
|
+
attr_reader :asset_type
|
138
|
+
attr_reader :compression
|
139
|
+
attr_reader :name
|
140
|
+
attr_reader :rendition_name
|
141
|
+
attr_reader :scale
|
142
|
+
attr_reader :size
|
143
|
+
attr_reader :relative_name
|
144
|
+
|
145
|
+
def initialize(asset)
|
146
|
+
@asset = asset
|
147
|
+
@size = asset['SizeOnDisk'] || 0
|
148
|
+
@asset_type = asset['AssetType']
|
149
|
+
@compression = asset['Compression']
|
150
|
+
@name = asset['Name'] || ''
|
151
|
+
@scale = asset['Scale']
|
152
|
+
@rendition_name = asset['RenditionName']
|
153
|
+
end
|
154
|
+
|
155
|
+
def unique_name
|
156
|
+
"#{@name}/#{compression}/#{@rendition_name}"
|
157
|
+
end
|
158
|
+
|
159
|
+
def to_s
|
160
|
+
@asset
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ioscmpr/size_diff'
|
4
|
+
|
5
|
+
module Ioscmpr
|
6
|
+
class Command
|
7
|
+
class Ipa < Command
|
8
|
+
self.summary = '对比 ipa'
|
9
|
+
self.description = '对比 ipa 中各文件差异.'
|
10
|
+
|
11
|
+
self.arguments = [
|
12
|
+
CLAide::Argument.new('PATHS', true, true)
|
13
|
+
]
|
14
|
+
|
15
|
+
def self.options
|
16
|
+
[
|
17
|
+
['--except-symbols', '不对比符号文件'],
|
18
|
+
['--visible-zero', '显示大小为 0 的文件(单 ipa 生效)'],
|
19
|
+
['--visible-same', '显示大小一致的对比']
|
20
|
+
].concat(super)
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(argv)
|
24
|
+
@paths = argv.arguments!
|
25
|
+
@except_symbols = argv.flag?('except-symbols')
|
26
|
+
@visible_zero = argv.flag?('visible-zero')
|
27
|
+
@visible_same = argv.flag?('visible-same')
|
28
|
+
super
|
29
|
+
end
|
30
|
+
|
31
|
+
def validate!
|
32
|
+
files = @paths.reject { |path| File.file?(path) }
|
33
|
+
help! '必须传入至少一个 ipa 文件路径' unless @paths.any?
|
34
|
+
help! "#{files} 需要为 ipa 压缩文件" unless files.empty?
|
35
|
+
end
|
36
|
+
|
37
|
+
def run
|
38
|
+
@ipas = Array(@paths).map { |path| IpaEntity.new(path) }
|
39
|
+
|
40
|
+
names = @ipas.map(&:files).flatten.sort_by(&:size).reject do |file|
|
41
|
+
next true if !@visible_zero && file.size.zero?
|
42
|
+
|
43
|
+
File.extname(file.name) == '.symbols' && @except_symbols
|
44
|
+
end.map(&:name).uniq
|
45
|
+
|
46
|
+
options = {
|
47
|
+
filter_zero_gap_sum: !@visible_same,
|
48
|
+
items_accessor: ->(ipa) { ipa.files }
|
49
|
+
}
|
50
|
+
|
51
|
+
diff = SizeDiff.new(@ipas, names, options)
|
52
|
+
diff.run
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
module Ioscmpr
|
59
|
+
class IpaEntity
|
60
|
+
attr_reader :length
|
61
|
+
attr_reader :size
|
62
|
+
attr_reader :cmpr
|
63
|
+
attr_reader :files
|
64
|
+
|
65
|
+
def initialize(path)
|
66
|
+
@path = path
|
67
|
+
|
68
|
+
analyze_cmpr
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def analyze_cmpr
|
74
|
+
result = `unzip -lv '#{@path}'`
|
75
|
+
lines = result.scrub.split("\n")
|
76
|
+
@files = lines.drop(3).reverse.drop(2).reverse.map do |line|
|
77
|
+
splited = line.split(' ', 8)
|
78
|
+
item = IpaEntity::File.new(splited)
|
79
|
+
item
|
80
|
+
end
|
81
|
+
|
82
|
+
@length, @size, @cmpr = lines.last&.split(' ')
|
83
|
+
end
|
84
|
+
|
85
|
+
def to_s
|
86
|
+
"#{length} #{size} #{cmpr} #{files.count}"
|
87
|
+
end
|
88
|
+
|
89
|
+
class File
|
90
|
+
attr_reader :length
|
91
|
+
attr_reader :method
|
92
|
+
attr_reader :size
|
93
|
+
attr_reader :cmpr
|
94
|
+
attr_reader :date
|
95
|
+
attr_reader :time
|
96
|
+
attr_reader :crc32
|
97
|
+
attr_reader :name
|
98
|
+
|
99
|
+
def initialize(params)
|
100
|
+
@length,
|
101
|
+
@method,
|
102
|
+
@size,
|
103
|
+
@cmpr,
|
104
|
+
@date,
|
105
|
+
@time,
|
106
|
+
@crc32,
|
107
|
+
@name = params
|
108
|
+
|
109
|
+
@size = @size.to_i
|
110
|
+
@length = @length.to_i
|
111
|
+
end
|
112
|
+
|
113
|
+
def empty?
|
114
|
+
length == 0
|
115
|
+
end
|
116
|
+
|
117
|
+
def to_s
|
118
|
+
"#{cmpr}\tBefore: #{length}\t\tAfter: #{size} #{name}"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,348 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pathname'
|
4
|
+
require 'ioscmpr/size_diff'
|
5
|
+
|
6
|
+
module Ioscmpr
|
7
|
+
class Command
|
8
|
+
class Linkmap < Command
|
9
|
+
PARTS = %w[libraries segments sections object_files symbols].freeze
|
10
|
+
|
11
|
+
self.summary = '对比 linkmap'
|
12
|
+
self.description = '对比 linkmap 中各部分差异.'
|
13
|
+
|
14
|
+
self.arguments = [
|
15
|
+
CLAide::Argument.new('PATHS', true, true)
|
16
|
+
]
|
17
|
+
|
18
|
+
def self.options
|
19
|
+
[
|
20
|
+
['--visible-same', '显示大小一致的对比'],
|
21
|
+
["--parts=#{PARTS.join(',')}", '需要对比的部分']
|
22
|
+
].concat(super)
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(argv)
|
26
|
+
@paths = argv.arguments!
|
27
|
+
@parts = argv.option('parts', PARTS.first).split(',')
|
28
|
+
@visible_same = argv.flag?('visible-same')
|
29
|
+
super
|
30
|
+
end
|
31
|
+
|
32
|
+
def validate!
|
33
|
+
files = @paths.reject { |path| File.file?(path) }
|
34
|
+
invalid_parts = @parts.reject { |part| PARTS.find { |dp| dp == part } }
|
35
|
+
|
36
|
+
help! '必须传入至少一个 linkmap 文件路径' unless @paths.any?
|
37
|
+
help! "#{files} 需要为 linkmap 文件" unless files.empty?
|
38
|
+
help! "无法对比 linkmap 的 #{invalid_parts} 部分" unless invalid_parts.empty?
|
39
|
+
end
|
40
|
+
|
41
|
+
def run
|
42
|
+
@results = @paths.map { |path| LinkmapEntity.new(path).result }
|
43
|
+
|
44
|
+
@parts.map(&:to_sym).each do |part|
|
45
|
+
names = @results.map(&part).flatten.sort_by(&:size).map(&:name).uniq
|
46
|
+
sets = @results.map { |r| r.send(part) }
|
47
|
+
|
48
|
+
items_accessor = lambda do |group|
|
49
|
+
group.each_with_object({}) do |item, hash|
|
50
|
+
hash[item.name] = item
|
51
|
+
end
|
52
|
+
end
|
53
|
+
options = {
|
54
|
+
filter_zero_gap_sum: !@visible_same,
|
55
|
+
head: part.to_s.capitalize,
|
56
|
+
items_accessor: items_accessor
|
57
|
+
}
|
58
|
+
|
59
|
+
diff = SizeDiff.new(sets, names, options)
|
60
|
+
diff.run
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
module Ioscmpr
|
68
|
+
class LinkmapEntity
|
69
|
+
class Result
|
70
|
+
PREFIX = '#'
|
71
|
+
|
72
|
+
attr_reader :sections
|
73
|
+
attr_reader :object_files
|
74
|
+
attr_reader :symbols
|
75
|
+
attr_reader :segments
|
76
|
+
|
77
|
+
def initialize
|
78
|
+
@sections = []
|
79
|
+
@object_file_index_hash = {}
|
80
|
+
end
|
81
|
+
|
82
|
+
def segments
|
83
|
+
@segments ||= sections.group_by(&:segment).map do |key, value|
|
84
|
+
Segment.new(key, value)
|
85
|
+
end.sort_by(&:size).reverse
|
86
|
+
end
|
87
|
+
|
88
|
+
def symbols
|
89
|
+
@symbols ||= object_files.map(&:symbols).flatten.uniq.sort_by(&:size).reverse
|
90
|
+
end
|
91
|
+
|
92
|
+
def object_files
|
93
|
+
@object_files ||= @object_file_index_hash.values.uniq.sort_by(&:size).reverse
|
94
|
+
end
|
95
|
+
|
96
|
+
def libraries
|
97
|
+
@libraries ||= object_files.group_by(&:extra_name).map do |key, value|
|
98
|
+
Library.new(key, value)
|
99
|
+
end.sort_by(&:size).reverse
|
100
|
+
end
|
101
|
+
|
102
|
+
def add_section(section)
|
103
|
+
@sections << section
|
104
|
+
end
|
105
|
+
|
106
|
+
def add_symbol(symbol)
|
107
|
+
object_file = @object_file_index_hash[symbol.index]
|
108
|
+
object_file.add_symbol(symbol)
|
109
|
+
end
|
110
|
+
|
111
|
+
def add_object_file(object_file)
|
112
|
+
@object_file_index_hash[object_file.index] = object_file
|
113
|
+
end
|
114
|
+
|
115
|
+
class Base
|
116
|
+
attr_reader :line
|
117
|
+
|
118
|
+
def initialize(line)
|
119
|
+
@line = line
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.from_line(line)
|
123
|
+
new line
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
class Library
|
128
|
+
attr_reader :object_files
|
129
|
+
attr_reader :size
|
130
|
+
attr_reader :name
|
131
|
+
|
132
|
+
def initialize(name, object_files)
|
133
|
+
@name = name
|
134
|
+
@object_files = object_files || []
|
135
|
+
end
|
136
|
+
|
137
|
+
def add_object_file(object_file)
|
138
|
+
@object_files << object_file
|
139
|
+
end
|
140
|
+
|
141
|
+
def size
|
142
|
+
@size ||= @object_files.map(&:size).reduce(&:+) || 0
|
143
|
+
end
|
144
|
+
|
145
|
+
def to_s
|
146
|
+
"#{name} #{size}"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
class Segment
|
151
|
+
attr_reader :name
|
152
|
+
attr_reader :sections
|
153
|
+
|
154
|
+
def initialize(name, sections)
|
155
|
+
@name = name
|
156
|
+
@sections = sections
|
157
|
+
end
|
158
|
+
|
159
|
+
def size
|
160
|
+
@size ||= sections.map(&:size).reduce(&:+) || 0
|
161
|
+
end
|
162
|
+
|
163
|
+
def to_s
|
164
|
+
"#{name} #{size}"
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
class ObjectFile < Base
|
169
|
+
DECLARE = 'Object files:'
|
170
|
+
|
171
|
+
attr_reader :index
|
172
|
+
attr_reader :name
|
173
|
+
attr_reader :extra_name
|
174
|
+
attr_reader :symbols
|
175
|
+
attr_reader :size
|
176
|
+
|
177
|
+
def initialize(line)
|
178
|
+
@index = line.match(/\[\s*(\d+)\]/)[1]
|
179
|
+
basename = File.basename(line.split(' ').last)
|
180
|
+
match_result = basename.match(/(.*)\((.*)\)/)
|
181
|
+
if match_result
|
182
|
+
@extra_name, @name = match_result[1, 2]
|
183
|
+
else
|
184
|
+
@extra_name = @name = basename
|
185
|
+
end
|
186
|
+
|
187
|
+
@symbols = []
|
188
|
+
|
189
|
+
super line
|
190
|
+
end
|
191
|
+
|
192
|
+
def size
|
193
|
+
@size ||= @symbols.map(&:size).reduce(&:+) || 0
|
194
|
+
end
|
195
|
+
|
196
|
+
def add_symbol(symbol)
|
197
|
+
@symbols << symbol
|
198
|
+
end
|
199
|
+
|
200
|
+
def to_s
|
201
|
+
s = "#{index} #{size} #{extra_name}"
|
202
|
+
s << " #{name}" unless name == extra_name
|
203
|
+
s
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
class Section < Base
|
208
|
+
DECLARE = 'Sections:'
|
209
|
+
|
210
|
+
attr_reader :address
|
211
|
+
attr_reader :size
|
212
|
+
attr_reader :segment
|
213
|
+
attr_reader :name
|
214
|
+
|
215
|
+
def initialize(line)
|
216
|
+
@address,
|
217
|
+
@size,
|
218
|
+
@segment,
|
219
|
+
@name = line.split(' ')
|
220
|
+
@size = @size.hex
|
221
|
+
|
222
|
+
super line
|
223
|
+
end
|
224
|
+
|
225
|
+
def to_s
|
226
|
+
@line
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
class Symbol < Base
|
231
|
+
DECLARE = 'Symbols:'
|
232
|
+
|
233
|
+
attr_reader :address
|
234
|
+
attr_reader :size
|
235
|
+
attr_reader :index
|
236
|
+
attr_reader :name
|
237
|
+
|
238
|
+
def initialize(line)
|
239
|
+
first_part, second_part = line.split('[', 2)
|
240
|
+
@address, @size = first_part.split(' ')
|
241
|
+
@index, @name = second_part.split(']', 2)
|
242
|
+
@index = @index.strip
|
243
|
+
@has_analyze_oc_method = false
|
244
|
+
|
245
|
+
super line
|
246
|
+
end
|
247
|
+
|
248
|
+
def class_name
|
249
|
+
analyze_oc_method
|
250
|
+
@class_name
|
251
|
+
end
|
252
|
+
|
253
|
+
def method_name
|
254
|
+
analyze_oc_method
|
255
|
+
@method_name
|
256
|
+
end
|
257
|
+
|
258
|
+
def oc_method?
|
259
|
+
analyze_oc_method
|
260
|
+
@oc_method_analyze_result
|
261
|
+
end
|
262
|
+
|
263
|
+
def instance_method?
|
264
|
+
analyze_oc_method
|
265
|
+
@is_instance_method
|
266
|
+
end
|
267
|
+
|
268
|
+
def size
|
269
|
+
@size.hex || 0
|
270
|
+
end
|
271
|
+
|
272
|
+
def dead?
|
273
|
+
@address == '<<dead>>'
|
274
|
+
end
|
275
|
+
|
276
|
+
def to_s
|
277
|
+
"#{address} #{size} [#{index}] #{name}"
|
278
|
+
end
|
279
|
+
|
280
|
+
private
|
281
|
+
|
282
|
+
def analyze_oc_method
|
283
|
+
return if @has_analyze_oc_method
|
284
|
+
|
285
|
+
@oc_method_analyze_result = @name.match(/([+|-])\[(.*)\s(.*)\]/)
|
286
|
+
if @oc_method_analyze_result
|
287
|
+
op, @class_name, @method_name = @oc_method_analyze_result[1, 3]
|
288
|
+
end
|
289
|
+
@is_instance_method = op == '-' if op
|
290
|
+
@has_analyze_oc_method = true
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
class Parser
|
296
|
+
attr_reader :result
|
297
|
+
|
298
|
+
def initialize(file)
|
299
|
+
@file = file
|
300
|
+
@result = Result.new
|
301
|
+
end
|
302
|
+
|
303
|
+
def parse
|
304
|
+
lines = open(@file).read.scrub.split("\n")
|
305
|
+
|
306
|
+
declare = nil
|
307
|
+
lines.each do |line|
|
308
|
+
next if line.empty?
|
309
|
+
|
310
|
+
if line.start_with?(Result::PREFIX)
|
311
|
+
find_result = [
|
312
|
+
Result::ObjectFile::DECLARE,
|
313
|
+
Result::Section::DECLARE,
|
314
|
+
Result::Symbol::DECLARE
|
315
|
+
].find { |dec| line.include?(dec) }
|
316
|
+
|
317
|
+
declare = find_result if find_result
|
318
|
+
next if line.start_with?(Result::PREFIX)
|
319
|
+
end
|
320
|
+
|
321
|
+
case declare
|
322
|
+
when Result::ObjectFile::DECLARE
|
323
|
+
object_file = Result::ObjectFile.from_line(line)
|
324
|
+
result.add_object_file(object_file)
|
325
|
+
when Result::Section::DECLARE
|
326
|
+
section = Result::Section.from_line(line)
|
327
|
+
result.add_section(section)
|
328
|
+
when Result::Symbol::DECLARE
|
329
|
+
symbol = Result::Symbol.from_line(line)
|
330
|
+
result.add_symbol(symbol)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
def initialize(path)
|
337
|
+
@path = path
|
338
|
+
end
|
339
|
+
|
340
|
+
def result
|
341
|
+
@result ||= begin
|
342
|
+
parser = Parser.new(Pathname.new(@path))
|
343
|
+
parser.parse
|
344
|
+
parser.result
|
345
|
+
end
|
346
|
+
end
|
347
|
+
end
|
348
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# https://github.com/google/bloaty
|
4
|
+
# brew install cmake
|
5
|
+
# cmake .
|
6
|
+
# make -j6
|
7
|
+
|
8
|
+
require 'ioscmpr/size_diff'
|
9
|
+
|
10
|
+
module Ioscmpr
|
11
|
+
class Command
|
12
|
+
class Macho < Command
|
13
|
+
self.summary = '对比 macho'
|
14
|
+
self.description = '对比 macho 中各段差异.'
|
15
|
+
|
16
|
+
self.arguments = [
|
17
|
+
CLAide::Argument.new('PATHS', true, true)
|
18
|
+
]
|
19
|
+
|
20
|
+
def self.options
|
21
|
+
[
|
22
|
+
['--visible-same', '显示大小一致的对比'],
|
23
|
+
['--with-thin', '瘦身后进行对比, 以 ARM64 为基准']
|
24
|
+
].concat(super)
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize(argv)
|
28
|
+
@paths = argv.arguments!
|
29
|
+
@visible_same = argv.flag?('visible-same')
|
30
|
+
@with_thin = argv.flag?('with-thin')
|
31
|
+
super
|
32
|
+
end
|
33
|
+
|
34
|
+
def validate!
|
35
|
+
files = @paths.reject { |path| File.file?(path) }
|
36
|
+
help! '必须传入至少一个 macho 文件路径' unless @paths.any?
|
37
|
+
help! "#{files} 需要为 macho 文件" unless files.empty?
|
38
|
+
end
|
39
|
+
|
40
|
+
def run
|
41
|
+
prepare
|
42
|
+
|
43
|
+
@info_pairs = @paths.map { |path| MachoEntity.new(path).info_pair }
|
44
|
+
names = @info_pairs.map(&:items).flatten.sort_by(&:size).map(&:name).uniq
|
45
|
+
|
46
|
+
clean
|
47
|
+
|
48
|
+
options = {
|
49
|
+
filter_zero_gap_sum: !@visible_same,
|
50
|
+
items_accessor: ->(info_pair) { info_pair.items },
|
51
|
+
calculate_totals: false
|
52
|
+
}
|
53
|
+
|
54
|
+
diff = SizeDiff.new(@info_pairs, names, options)
|
55
|
+
diff.run
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def work_dir
|
61
|
+
File.join(Dir.pwd, '.ioscmpr.macho')
|
62
|
+
end
|
63
|
+
|
64
|
+
def prepare
|
65
|
+
if @with_thin
|
66
|
+
Dir.mkdir(work_dir) unless File.directory?(work_dir)
|
67
|
+
|
68
|
+
not_fat_machos = @paths.select do |path|
|
69
|
+
archs = `xcrun lipo -archs '#{path}'`.split(' ')
|
70
|
+
archs.count <= 1 || !archs.include?('arm64')
|
71
|
+
end
|
72
|
+
return if not_fat_machos.any?
|
73
|
+
|
74
|
+
outputs = []
|
75
|
+
@paths.each_with_index do |path, index|
|
76
|
+
output = File.join(work_dir, index.to_s)
|
77
|
+
`xcrun lipo -thin arm64 '#{path}' -output '#{output}'`
|
78
|
+
outputs << output
|
79
|
+
end
|
80
|
+
@paths = outputs
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def clean
|
85
|
+
FileUtils.rm_rf(work_dir) if @with_thin && File.directory?(work_dir)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
module Ioscmpr
|
92
|
+
class MachoEntity
|
93
|
+
class Item
|
94
|
+
attr_reader :name
|
95
|
+
attr_reader :file_size
|
96
|
+
attr_reader :vm_size
|
97
|
+
alias size file_size
|
98
|
+
|
99
|
+
def initialize(line)
|
100
|
+
@line = line
|
101
|
+
@file_per, @file_size, @vm_per, @vm_size, @name =
|
102
|
+
line.split(' ', 5)
|
103
|
+
|
104
|
+
@file_size = transform_size(@file_size)
|
105
|
+
@vm_size = transform_size(@vm_size)
|
106
|
+
end
|
107
|
+
|
108
|
+
def transform_size(size)
|
109
|
+
if size.end_with?('Ki')
|
110
|
+
size.sub('Ki', '').to_i * 1024
|
111
|
+
elsif size.end_with?('Mi')
|
112
|
+
size.sub('Mi', '').to_i * 1024 * 1024
|
113
|
+
else
|
114
|
+
size.to_i
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def to_s
|
119
|
+
@line
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
class InfoPair
|
124
|
+
attr_reader :items
|
125
|
+
attr_reader :name
|
126
|
+
|
127
|
+
def initialize(name)
|
128
|
+
@name = name
|
129
|
+
@items = []
|
130
|
+
end
|
131
|
+
|
132
|
+
def add_items(items)
|
133
|
+
@items += items
|
134
|
+
end
|
135
|
+
|
136
|
+
def to_s
|
137
|
+
"#{name}: \n#{items.join("\n")}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def initialize(path)
|
142
|
+
@path = path
|
143
|
+
end
|
144
|
+
|
145
|
+
def info_pair
|
146
|
+
@info_pair ||= begin
|
147
|
+
info_pair = InfoPair.new(@path)
|
148
|
+
items = bloaty(@path).map { |line| Item.new(line) }
|
149
|
+
info_pair.add_items(items)
|
150
|
+
info_pair
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
def bloaty(_path)
|
157
|
+
result = `#{__dir__}/bloaty '#{@path}' -n 0 -s file`.split("\n").reject(&:empty?).drop(2)
|
158
|
+
result
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'terminal-table'
|
4
|
+
require 'colorize'
|
5
|
+
|
6
|
+
module Ioscmpr
|
7
|
+
class SizeDiff
|
8
|
+
class Row
|
9
|
+
attr_reader :values
|
10
|
+
attr_reader :name
|
11
|
+
attr_reader :gaps
|
12
|
+
attr_accessor :ref_value
|
13
|
+
|
14
|
+
def initialize(name)
|
15
|
+
@name = name
|
16
|
+
@values = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def gaps
|
20
|
+
@values.drop(1).map { |value| value - ref_value }
|
21
|
+
end
|
22
|
+
|
23
|
+
def gap_abs_sum
|
24
|
+
gaps.map(&:abs).reduce(&:+)
|
25
|
+
end
|
26
|
+
|
27
|
+
def add_value(value)
|
28
|
+
@ref_value ||= value
|
29
|
+
@values << value
|
30
|
+
end
|
31
|
+
|
32
|
+
def color_gap(gap)
|
33
|
+
abs_gap = gap.abs
|
34
|
+
if gap.positive?
|
35
|
+
"↑#{abs_gap}".red
|
36
|
+
elsif gap.negative?
|
37
|
+
"↓#{abs_gap}".green
|
38
|
+
else
|
39
|
+
abs_gap.to_s.white
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def row
|
44
|
+
local_gaps = gaps
|
45
|
+
[name, values.first, *values.drop(1).map.with_index { |value, index| format('%-10s [%10s]', value, color_gap(local_gaps[index])) }]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
DEFAULT_OPTIONS = {
|
50
|
+
filter_zero_gap_sum: true,
|
51
|
+
sort_by_gap_index: 0,
|
52
|
+
calculate_totals: true,
|
53
|
+
head: 'Name',
|
54
|
+
item_finder: nil,
|
55
|
+
items_accessor: nil
|
56
|
+
}.freeze
|
57
|
+
|
58
|
+
def initialize(groups, names, options = DEFAULT_OPTIONS)
|
59
|
+
@groups = groups
|
60
|
+
@names = names
|
61
|
+
init_option_values(options)
|
62
|
+
end
|
63
|
+
|
64
|
+
def init_option_values(options)
|
65
|
+
DEFAULT_OPTIONS.each do |key, default|
|
66
|
+
value = options.key?(key) ? options[key] : default
|
67
|
+
instance_variable_set("@#{key}", value)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def run
|
72
|
+
table = Terminal::Table.new(headings: [@head] + [*1..@groups.count])
|
73
|
+
|
74
|
+
groups = @groups.map do |group|
|
75
|
+
group = @items_accessor ? @items_accessor.call(group) : group
|
76
|
+
group
|
77
|
+
end
|
78
|
+
|
79
|
+
rows = @names.map do |name|
|
80
|
+
row = Row.new(name)
|
81
|
+
|
82
|
+
groups.each do |items|
|
83
|
+
item = if @item_finder
|
84
|
+
@item_finder.call(items, name)
|
85
|
+
else
|
86
|
+
if items.is_a?(Hash)
|
87
|
+
items[name]
|
88
|
+
else
|
89
|
+
items.find { |i| i.name == name }
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
size = item&.size || 0
|
94
|
+
|
95
|
+
row.add_value(size)
|
96
|
+
end
|
97
|
+
|
98
|
+
row
|
99
|
+
end
|
100
|
+
|
101
|
+
if groups.count > 1
|
102
|
+
rows = rows.sort do |r1, r2|
|
103
|
+
r1.gaps[@sort_by_gap_index]&.abs <=> r2.gaps[@sort_by_gap_index]&.abs
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
rows.each do |row|
|
108
|
+
next if row.gap_abs_sum&.zero? && @filter_zero_gap_sum
|
109
|
+
|
110
|
+
table.add_row(row.row)
|
111
|
+
end
|
112
|
+
|
113
|
+
if @calculate_totals
|
114
|
+
totals = []
|
115
|
+
rows.each do |row|
|
116
|
+
row.values.each_with_index do |value, index|
|
117
|
+
totals[index] ||= value
|
118
|
+
totals[index] += value
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
row = Row.new('TOTALS')
|
123
|
+
totals.each do |value|
|
124
|
+
row.add_value(value)
|
125
|
+
end
|
126
|
+
table.add_row(row.row)
|
127
|
+
end
|
128
|
+
|
129
|
+
puts table
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
metadata
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ioscmpr
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- songruiwang
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-02-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: claide
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: colorize
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: terminal-table
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: bundler
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.17'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.17'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rake
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '10.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '10.0'
|
83
|
+
description: Write a longer description or delete this line.
|
84
|
+
email:
|
85
|
+
- songruiwang@kuaishou.com
|
86
|
+
executables:
|
87
|
+
- ioscmpr
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- ".gitignore"
|
92
|
+
- Gemfile
|
93
|
+
- Gemfile.lock
|
94
|
+
- README.md
|
95
|
+
- Rakefile
|
96
|
+
- bin/ioscmpr
|
97
|
+
- ioscmpr.gemspec
|
98
|
+
- lib/ioscmpr.rb
|
99
|
+
- lib/ioscmpr/command.rb
|
100
|
+
- lib/ioscmpr/command/bloaty
|
101
|
+
- lib/ioscmpr/command/car.rb
|
102
|
+
- lib/ioscmpr/command/ipa.rb
|
103
|
+
- lib/ioscmpr/command/linkmap.rb
|
104
|
+
- lib/ioscmpr/command/macho.rb
|
105
|
+
- lib/ioscmpr/size_diff.rb
|
106
|
+
- lib/ioscmpr/version.rb
|
107
|
+
homepage: https://github.com/tripleCC/ioscmpr
|
108
|
+
licenses: []
|
109
|
+
metadata: {}
|
110
|
+
post_install_message:
|
111
|
+
rdoc_options: []
|
112
|
+
require_paths:
|
113
|
+
- lib
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - ">="
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
requirements: []
|
125
|
+
rubygems_version: 3.1.2
|
126
|
+
signing_key:
|
127
|
+
specification_version: 4
|
128
|
+
summary: Write a short summary, because RubyGems requires one.
|
129
|
+
test_files: []
|