app-info 2.2.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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +3 -0
- data/.rubocop.yml +40 -0
- data/.travis.yml +9 -0
- data/CHANGELOG.md +96 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +7 -0
- data/LICENSE +21 -0
- data/README.md +246 -0
- data/Rakefile +10 -0
- data/app_info.gemspec +33 -0
- data/lib/app-info.rb +3 -0
- data/lib/app_info.rb +88 -0
- data/lib/app_info/apk.rb +165 -0
- data/lib/app_info/core_ext/object/try.rb +114 -0
- data/lib/app_info/dsym.rb +150 -0
- data/lib/app_info/ipa.rb +173 -0
- data/lib/app_info/ipa/info_plist.rb +146 -0
- data/lib/app_info/ipa/mobile_provision.rb +260 -0
- data/lib/app_info/proguard.rb +90 -0
- data/lib/app_info/util.rb +58 -0
- data/lib/app_info/version.rb +5 -0
- metadata +231 -0
data/Rakefile
ADDED
data/app_info.gemspec
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('../lib', __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'app_info/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'app-info'
|
9
|
+
spec.version = AppInfo::VERSION
|
10
|
+
spec.authors = ['icyleaf']
|
11
|
+
spec.email = ['icyleaf.cn@gmail.com']
|
12
|
+
|
13
|
+
spec.summary = 'Teardown tool for mobile app(ipa/apk) and dSYM file, analysis metedata like version, name, icon'
|
14
|
+
spec.description = 'Teardown tool for ipa/apk files and dSYM file, even support for info.plist and .mobileprovision files'
|
15
|
+
spec.homepage = 'http://github.com/icyleaf/app-info'
|
16
|
+
spec.license = 'MIT'
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
spec.required_ruby_version = '>= 2.3'
|
21
|
+
|
22
|
+
spec.add_dependency 'CFPropertyList', ['< 3.1.0', '>= 2.3.4']
|
23
|
+
spec.add_dependency 'image_size', '>= 1.5', '< 2.1'
|
24
|
+
spec.add_dependency 'pngdefry', '~> 0.1.2'
|
25
|
+
spec.add_dependency 'ruby-macho', '~> 2.2.0'
|
26
|
+
spec.add_dependency 'ruby_android', '~> 0.7.7'
|
27
|
+
spec.add_dependency 'rubyzip', '>= 1.2', '< 3.0'
|
28
|
+
spec.add_dependency 'uuidtools', '>= 2.1.5', '< 2.3.0'
|
29
|
+
|
30
|
+
spec.add_development_dependency 'bundler', '>= 1.12'
|
31
|
+
spec.add_development_dependency 'rake', '>= 10.0'
|
32
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
33
|
+
end
|
data/lib/app-info.rb
ADDED
data/lib/app_info.rb
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'app_info/core_ext/object/try'
|
4
|
+
require 'app_info/version'
|
5
|
+
require 'app_info/ipa'
|
6
|
+
require 'app_info/ipa/info_plist'
|
7
|
+
require 'app_info/ipa/mobile_provision'
|
8
|
+
require 'app_info/apk'
|
9
|
+
require 'app_info/dsym'
|
10
|
+
require 'app_info/proguard'
|
11
|
+
|
12
|
+
# AppInfo Module
|
13
|
+
module AppInfo
|
14
|
+
class Error < StandardError; end
|
15
|
+
class NotFoundError < Error; end
|
16
|
+
class UnkownFileTypeError < Error; end
|
17
|
+
|
18
|
+
# App Platform
|
19
|
+
module Platform
|
20
|
+
IOS = 'iOS'
|
21
|
+
ANDROID = 'Android'
|
22
|
+
DSYM = 'dSYM'
|
23
|
+
PROGUARD = 'Proguard'
|
24
|
+
end
|
25
|
+
|
26
|
+
# Get a new parser for automatic
|
27
|
+
def self.parse(file)
|
28
|
+
raise NotFoundError, file unless File.exist?(file)
|
29
|
+
|
30
|
+
case file_type(file)
|
31
|
+
when :ipa then IPA.new(file)
|
32
|
+
when :apk then APK.new(file)
|
33
|
+
when :mobileprovision then MobileProvision.new(file)
|
34
|
+
when :dsym then DSYM.new(file)
|
35
|
+
when :proguard then Proguard.new(file)
|
36
|
+
else
|
37
|
+
raise UnkownFileTypeError, "Sorry, AppInfo can not detect file type: #{file}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
singleton_class.send(:alias_method, :dump, :parse)
|
41
|
+
|
42
|
+
# Detect file type by read file header
|
43
|
+
#
|
44
|
+
# TODO: This can be better way to solvt, if anyone knows, tell me please.
|
45
|
+
def self.file_type(file)
|
46
|
+
header_hex = IO.read(file, 100)
|
47
|
+
type = if header_hex =~ /^\x50\x4b\x03\x04/
|
48
|
+
detect_zip_file(file)
|
49
|
+
else
|
50
|
+
detect_mobileprovision(header_hex)
|
51
|
+
end
|
52
|
+
|
53
|
+
type || :unkown
|
54
|
+
end
|
55
|
+
|
56
|
+
# :nodoc:
|
57
|
+
def self.detect_zip_file(file)
|
58
|
+
Zip.warn_invalid_date = false
|
59
|
+
zip_file = Zip::File.open(file)
|
60
|
+
|
61
|
+
return :proguard unless zip_file.glob('*mapping*.txt').empty?
|
62
|
+
return :apk unless zip_file.find_entry('AndroidManifest.xml').nil? &&
|
63
|
+
zip_file.find_entry('classes.dex').nil?
|
64
|
+
|
65
|
+
zip_file.each do |f|
|
66
|
+
path = f.name
|
67
|
+
|
68
|
+
return :ipa if path.include?('Payload/') && path.end_with?('Info.plist')
|
69
|
+
return :dsym if path.include?('Contents/Resources/DWARF/')
|
70
|
+
end
|
71
|
+
end
|
72
|
+
private_class_method :detect_zip_file
|
73
|
+
|
74
|
+
# :nodoc:
|
75
|
+
def self.detect_mobileprovision(hex)
|
76
|
+
if hex =~ /^\x3C\x3F\x78\x6D\x6C/
|
77
|
+
# plist
|
78
|
+
:mobileprovision
|
79
|
+
elsif hex =~ /^\x62\x70\x6C\x69\x73\x74/
|
80
|
+
# bplist
|
81
|
+
:mobileprovision
|
82
|
+
elsif hex =~ /\x3C\x3F\x78\x6D\x6C/
|
83
|
+
# signed plist
|
84
|
+
:mobileprovision
|
85
|
+
end
|
86
|
+
end
|
87
|
+
private_class_method :detect_mobileprovision
|
88
|
+
end
|
data/lib/app_info/apk.rb
ADDED
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ruby_apk'
|
4
|
+
require 'image_size'
|
5
|
+
require 'forwardable'
|
6
|
+
require 'app_info/util'
|
7
|
+
|
8
|
+
module AppInfo
|
9
|
+
# Parse APK file
|
10
|
+
class APK
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
attr_reader :file, :apk
|
14
|
+
|
15
|
+
# APK Devices
|
16
|
+
module Device
|
17
|
+
PHONE = 'Phone'
|
18
|
+
TABLET = 'Tablet'
|
19
|
+
WATCH = 'Watch'
|
20
|
+
TV = 'Television'
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(file)
|
24
|
+
@file = file
|
25
|
+
|
26
|
+
Zip.warn_invalid_date = false # fix invaild date format warnings
|
27
|
+
@apk = ::Android::Apk.new(file)
|
28
|
+
end
|
29
|
+
|
30
|
+
def size(humanable = false)
|
31
|
+
AppInfo::Util.file_size(@file, humanable)
|
32
|
+
end
|
33
|
+
|
34
|
+
def os
|
35
|
+
AppInfo::Platform::ANDROID
|
36
|
+
end
|
37
|
+
alias file_type os
|
38
|
+
|
39
|
+
def_delegators :@apk, :manifest, :resource, :dex
|
40
|
+
|
41
|
+
def_delegators :manifest, :version_name, :package_name,
|
42
|
+
:use_permissions, :components
|
43
|
+
|
44
|
+
alias release_version version_name
|
45
|
+
alias identifier package_name
|
46
|
+
alias bundle_id package_name
|
47
|
+
|
48
|
+
def version_code
|
49
|
+
manifest.version_code.to_s
|
50
|
+
end
|
51
|
+
alias build_version version_code
|
52
|
+
|
53
|
+
def name
|
54
|
+
resource.find('@string/app_name')
|
55
|
+
end
|
56
|
+
|
57
|
+
def device_type
|
58
|
+
if wear?
|
59
|
+
Device::WATCH
|
60
|
+
elsif tv?
|
61
|
+
Device::TV
|
62
|
+
else
|
63
|
+
Device::PHONE
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# TODO: find a way to detect
|
68
|
+
# def tablet?
|
69
|
+
# resource
|
70
|
+
# end
|
71
|
+
|
72
|
+
def wear?
|
73
|
+
use_features.include?('android.hardware.type.watch')
|
74
|
+
end
|
75
|
+
|
76
|
+
def tv?
|
77
|
+
use_features.include?('android.software.leanback')
|
78
|
+
end
|
79
|
+
|
80
|
+
def min_sdk_version
|
81
|
+
manifest.min_sdk_ver
|
82
|
+
end
|
83
|
+
|
84
|
+
def target_sdk_version
|
85
|
+
manifest.doc
|
86
|
+
.elements['/manifest/uses-sdk']
|
87
|
+
.attributes['targetSdkVersion']
|
88
|
+
.to_i
|
89
|
+
end
|
90
|
+
|
91
|
+
def use_features
|
92
|
+
manifest_values('/manifest/uses-feature')
|
93
|
+
end
|
94
|
+
|
95
|
+
def signs
|
96
|
+
@apk.signs.each_with_object([]) do |(path, sign), obj|
|
97
|
+
obj << Sign.new(path, sign)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def certificates
|
102
|
+
@apk.certificates.each_with_object([]) do |(path, certificate), obj|
|
103
|
+
obj << Certificate.new(path, certificate)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def activities
|
108
|
+
components.select { |c| c.type == 'activity' }
|
109
|
+
end
|
110
|
+
|
111
|
+
def services
|
112
|
+
components.select { |c| c.type == 'service' }
|
113
|
+
end
|
114
|
+
|
115
|
+
def icons
|
116
|
+
unless @icons
|
117
|
+
tmp_path = File.join(Dir.mktmpdir, "AppInfo-android-#{SecureRandom.hex}")
|
118
|
+
|
119
|
+
@icons = @apk.icon.each_with_object([]) do |(path, data), obj|
|
120
|
+
icon_name = File.basename(path)
|
121
|
+
icon_path = File.join(tmp_path, File.dirname(path))
|
122
|
+
icon_file = File.join(icon_path, icon_name)
|
123
|
+
FileUtils.mkdir_p icon_path
|
124
|
+
File.open(icon_file, 'wb') { |f| f.write(data) }
|
125
|
+
|
126
|
+
obj << {
|
127
|
+
name: icon_name,
|
128
|
+
file: icon_file,
|
129
|
+
dimensions: ImageSize.path(icon_file).size
|
130
|
+
}
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
@icons
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def manifest_values(path, key = 'name')
|
140
|
+
values = []
|
141
|
+
manifest.doc.each_element(path) do |elem|
|
142
|
+
values << elem.attributes[key]
|
143
|
+
end
|
144
|
+
values.uniq
|
145
|
+
end
|
146
|
+
|
147
|
+
# Android Certificate
|
148
|
+
class Certificate
|
149
|
+
attr_reader :path, :certificate
|
150
|
+
def initialize(path, certificate)
|
151
|
+
@path = path
|
152
|
+
@certificate = certificate
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Android Sign
|
157
|
+
class Sign
|
158
|
+
attr_reader :path, :sign
|
159
|
+
def initialize(path, sign)
|
160
|
+
@path = path
|
161
|
+
@sign = sign
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyrights rails
|
4
|
+
# Copy from https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/object/try.rb
|
5
|
+
|
6
|
+
module AppInfo
|
7
|
+
module Tryable #:nodoc:
|
8
|
+
def try(method_name = nil, *args, &block)
|
9
|
+
if method_name.nil? && block_given?
|
10
|
+
if block.arity.zero?
|
11
|
+
instance_eval(&b)
|
12
|
+
else
|
13
|
+
yield self
|
14
|
+
end
|
15
|
+
elsif respond_to?(method_name)
|
16
|
+
public_send(method_name, *args, &block)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def try!(method_name = nil, *args, &block)
|
21
|
+
if method_name.nil? && block_given?
|
22
|
+
if block.arity.zero?
|
23
|
+
instance_eval(&block)
|
24
|
+
else
|
25
|
+
yield self
|
26
|
+
end
|
27
|
+
else
|
28
|
+
public_send(method_name, *args, &block)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class Object
|
35
|
+
include AppInfo::Tryable
|
36
|
+
|
37
|
+
##
|
38
|
+
# :method: try
|
39
|
+
#
|
40
|
+
# :call-seq:
|
41
|
+
# try(*a, &b)
|
42
|
+
#
|
43
|
+
# Invokes the public method whose name goes as first argument just like
|
44
|
+
# +public_send+ does, except that if the receiver does not respond to it the
|
45
|
+
# call returns +nil+ rather than raising an exception.
|
46
|
+
#
|
47
|
+
# This method is defined to be able to write
|
48
|
+
#
|
49
|
+
# @person.try(:name)
|
50
|
+
#
|
51
|
+
# instead of
|
52
|
+
#
|
53
|
+
# @person.name if @person
|
54
|
+
#
|
55
|
+
# +try+ calls can be chained:
|
56
|
+
#
|
57
|
+
# @person.try(:spouse).try(:name)
|
58
|
+
#
|
59
|
+
# instead of
|
60
|
+
#
|
61
|
+
# @person.spouse.name if @person && @person.spouse
|
62
|
+
#
|
63
|
+
# +try+ will also return +nil+ if the receiver does not respond to the method:
|
64
|
+
#
|
65
|
+
# @person.try(:non_existing_method) # => nil
|
66
|
+
#
|
67
|
+
# instead of
|
68
|
+
#
|
69
|
+
# @person.non_existing_method if @person.respond_to?(:non_existing_method) # => nil
|
70
|
+
#
|
71
|
+
# +try+ returns +nil+ when called on +nil+ regardless of whether it responds
|
72
|
+
# to the method:
|
73
|
+
#
|
74
|
+
# nil.try(:to_i) # => nil, rather than 0
|
75
|
+
#
|
76
|
+
# Arguments and blocks are forwarded to the method if invoked:
|
77
|
+
#
|
78
|
+
# @posts.try(:each_slice, 2) do |a, b|
|
79
|
+
# ...
|
80
|
+
# end
|
81
|
+
#
|
82
|
+
# The number of arguments in the signature must match. If the object responds
|
83
|
+
# to the method the call is attempted and +ArgumentError+ is still raised
|
84
|
+
# in case of argument mismatch.
|
85
|
+
#
|
86
|
+
# If +try+ is called without arguments it yields the receiver to a given
|
87
|
+
# block unless it is +nil+:
|
88
|
+
#
|
89
|
+
# @person.try do |p|
|
90
|
+
# ...
|
91
|
+
# end
|
92
|
+
#
|
93
|
+
# You can also call try with a block without accepting an argument, and the block
|
94
|
+
# will be instance_eval'ed instead:
|
95
|
+
#
|
96
|
+
# @person.try { upcase.truncate(50) }
|
97
|
+
#
|
98
|
+
# Please also note that +try+ is defined on +Object+. Therefore, it won't work
|
99
|
+
# with instances of classes that do not have +Object+ among their ancestors,
|
100
|
+
# like direct subclasses of +BasicObject+.
|
101
|
+
|
102
|
+
##
|
103
|
+
# :method: try!
|
104
|
+
#
|
105
|
+
# :call-seq:
|
106
|
+
# try!(*a, &b)
|
107
|
+
#
|
108
|
+
# Same as #try, but raises a +NoMethodError+ exception if the receiver is
|
109
|
+
# not +nil+ and does not implement the tried method.
|
110
|
+
#
|
111
|
+
# "a".try!(:upcase) # => "A"
|
112
|
+
# nil.try!(:upcase) # => nil
|
113
|
+
# 123.try!(:upcase) # => NoMethodError: undefined method `upcase' for 123:Integer
|
114
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'macho'
|
4
|
+
require 'app_info/util'
|
5
|
+
|
6
|
+
module AppInfo
|
7
|
+
# DSYM parser
|
8
|
+
class DSYM
|
9
|
+
attr_reader :file
|
10
|
+
|
11
|
+
def initialize(file)
|
12
|
+
@file = file
|
13
|
+
end
|
14
|
+
|
15
|
+
def file_type
|
16
|
+
AppInfo::Platform::DSYM
|
17
|
+
end
|
18
|
+
|
19
|
+
def object
|
20
|
+
@object ||= File.basename(app_path)
|
21
|
+
end
|
22
|
+
|
23
|
+
def macho_type
|
24
|
+
@macho_type ||= ::MachO.open(app_path)
|
25
|
+
end
|
26
|
+
|
27
|
+
def machos
|
28
|
+
@machos ||= case macho_type
|
29
|
+
when ::MachO::MachOFile
|
30
|
+
[MachO.new(macho_type, File.size(app_path))]
|
31
|
+
else
|
32
|
+
size = macho_type.fat_archs.each_with_object([]) do |arch, obj|
|
33
|
+
obj << arch.size
|
34
|
+
end
|
35
|
+
|
36
|
+
machos = []
|
37
|
+
macho_type.machos.each_with_index do |file, i|
|
38
|
+
machos << MachO.new(file, size[i])
|
39
|
+
end
|
40
|
+
machos
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def release_version
|
45
|
+
info.try(:[], 'CFBundleShortVersionString')
|
46
|
+
end
|
47
|
+
|
48
|
+
def build_version
|
49
|
+
info.try(:[], 'CFBundleVersion')
|
50
|
+
end
|
51
|
+
|
52
|
+
def identifier
|
53
|
+
info.try(:[], 'CFBundleIdentifier').sub('com.apple.xcode.dsym.', '')
|
54
|
+
end
|
55
|
+
alias bundle_id identifier
|
56
|
+
|
57
|
+
def info
|
58
|
+
return nil unless File.exist?(info_path)
|
59
|
+
|
60
|
+
@info ||= CFPropertyList.native_types(CFPropertyList::List.new(file: info_path).value)
|
61
|
+
end
|
62
|
+
|
63
|
+
def info_path
|
64
|
+
@info_path ||= File.join(contents, 'Contents', 'Info.plist')
|
65
|
+
end
|
66
|
+
|
67
|
+
def app_path
|
68
|
+
unless @app_path
|
69
|
+
path = File.join(contents, 'Contents', 'Resources', 'DWARF')
|
70
|
+
name = Dir.entries(path).reject { |f| ['.', '..'].include?(f) }.first
|
71
|
+
@app_path = File.join(path, name)
|
72
|
+
end
|
73
|
+
|
74
|
+
@app_path
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def contents
|
80
|
+
unless @contents
|
81
|
+
if File.directory?(@file)
|
82
|
+
@contents = @file
|
83
|
+
else
|
84
|
+
dsym_dir = nil
|
85
|
+
@contents = Util.unarchive(@file, path: 'dsym') do |path, zip_file|
|
86
|
+
zip_file.each do |f|
|
87
|
+
unless dsym_dir
|
88
|
+
dsym_dir = f.name
|
89
|
+
dsym_dir = dsym_dir.split('/')[0] # fix filename is xxx.app.dSYM/Contents
|
90
|
+
end
|
91
|
+
|
92
|
+
f_path = File.join(path, f.name)
|
93
|
+
zip_file.extract(f, f_path) unless File.exist?(f_path)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
@contents = File.join(@contents, dsym_dir)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
@contents
|
102
|
+
end
|
103
|
+
|
104
|
+
# DSYM Mach-O
|
105
|
+
class MachO
|
106
|
+
def initialize(file, size = 0)
|
107
|
+
@file = file
|
108
|
+
@size = size
|
109
|
+
end
|
110
|
+
|
111
|
+
def cpu_name
|
112
|
+
@file.cpusubtype
|
113
|
+
end
|
114
|
+
|
115
|
+
def cpu_type
|
116
|
+
@file.cputype
|
117
|
+
end
|
118
|
+
|
119
|
+
def type
|
120
|
+
@file.filetype
|
121
|
+
end
|
122
|
+
|
123
|
+
def size(humanable = false)
|
124
|
+
return Util.size_to_humanable(@size) if humanable
|
125
|
+
|
126
|
+
@size
|
127
|
+
end
|
128
|
+
|
129
|
+
def uuid
|
130
|
+
@file[:LC_UUID][0].uuid_string
|
131
|
+
end
|
132
|
+
alias debug_id uuid
|
133
|
+
|
134
|
+
def header
|
135
|
+
@header ||= @file.header
|
136
|
+
end
|
137
|
+
|
138
|
+
def to_h
|
139
|
+
{
|
140
|
+
uuid: uuid,
|
141
|
+
type: type,
|
142
|
+
cpu_name: cpu_name,
|
143
|
+
cpu_type: cpu_type,
|
144
|
+
size: size,
|
145
|
+
humanable_size: size(true)
|
146
|
+
}
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|