cfbundle 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 34fbdac8d95981c1ee22c2cb3b57a5f80d1cd975
4
+ data.tar.gz: 85f292ed9c14e869a69b2d01c3238765fe29f5ae
5
+ SHA512:
6
+ metadata.gz: d162902ed081d3c2cc34d108e0d881846333eec364c9daa34208477c3c66f0f4e1f08389e008272dea1487570f60562ce71f6a515218ccaa4169e5953d59c104
7
+ data.tar.gz: b8fbc74c34d59c06bf626f744517c74419773c4732fe01b49e9d5e44a8569bfb7219380e7d42c8fb414bd0f87762546d62940b96e9298b100923a1135e0ac143
@@ -0,0 +1,255 @@
1
+ require 'cfbundle/constants'
2
+ require 'cfbundle/localization'
3
+ require 'cfbundle/path_utils'
4
+ require 'cfbundle/plist'
5
+ require 'cfbundle/resource'
6
+ require 'cfbundle/storage_detection'
7
+
8
+ module CFBundle
9
+ # A Bundle is an abstraction of a bundle accessible by the program.
10
+ class Bundle
11
+ # Opens a bundle.
12
+ #
13
+ # With no associated block, {open} is a synonym for {initialize new}. If the
14
+ # optional code block is given, it will be passed the opened bundle as an
15
+ # argument and the Bundle object will automatically be closed when the block
16
+ # terminates. The value of the block will be returned from the method.
17
+ #
18
+ # @param file [Object] The file to open. See {initialize} for a description
19
+ # of the supported values.
20
+ # @yieldparam bundle [Bundle] The opened bundle. It is automatically closed
21
+ # when the block terminates.
22
+ # @return [Object] The return value of the block when a block if given.
23
+ # @return [Bundle] The opened bundle when no block is given.
24
+ def self.open(file)
25
+ bundle = new(file)
26
+ return bundle unless block_given?
27
+ begin
28
+ yield bundle
29
+ ensure
30
+ bundle.close
31
+ end
32
+ end
33
+
34
+ # Opens the bundle and returns a new Bundle object.
35
+ #
36
+ # A new {storage} is automatically created from the +file+ parameter:
37
+ # * If the file is a path to a bundle directory, a new {Storage::FileSystem}
38
+ # is created that references the bundle at the path.
39
+ # * If the file is an +IO+ or a path to a ZIP archive, a new {Storage::Zip}
40
+ # is created that references the bundle within the archive.
41
+ # The +rubyzip+ gem must be loaded and the archive is expected to contain
42
+ # a single bundle at its root (or inside a +Payload+ directory for +.ipa+
43
+ # archives).
44
+ # * If the file is a {Storage::Base}, it is used as the bundle's storage.
45
+ #
46
+ # You should send +#close+ when the bundle is no longer needed.
47
+ #
48
+ # Note that storages created from an exisiting +IO+ do not automatically
49
+ # close the file when the bundle is closed.
50
+ #
51
+ # @param file [Object] The file to open.
52
+ # @raise [ArgumentError] If the file cannot be opened.
53
+ def initialize(file)
54
+ @storage = StorageDetection.open(file)
55
+ end
56
+
57
+ # Closes the bundle and its underlying storage.
58
+ # @return [void]
59
+ def close
60
+ @storage.close
61
+ end
62
+
63
+ # The abstract storage used by the bundle.
64
+ #
65
+ # The storage implements the methods that are used by Bundle to read the
66
+ # bundle from the underlying storage (ZIP archive or file system).
67
+ #
68
+ # @return [Storage::Base]
69
+ # @see Storage::FileSystem
70
+ # @see Storage::Zip
71
+ attr_reader :storage
72
+
73
+ # Returns the bundle's information property list hash.
74
+ # @return [Hash]
75
+ def info
76
+ @info ||= Plist.load_info_plist(self)
77
+ end
78
+
79
+ # Returns the bundle identifier from the bundle's information property list.
80
+ # @return [String, nil]
81
+ # @see INFO_KEY_BUNDLE_IDENTIFIER
82
+ def identifier
83
+ info_string(CFBundle::INFO_KEY_BUNDLE_IDENTIFIER)
84
+ end
85
+
86
+ # Returns the bundle's build version number.
87
+ # @return [String, nil]
88
+ # @see INFO_KEY_BUNDLE_VERSION
89
+ def build_version
90
+ info_string(CFBundle::INFO_KEY_BUNDLE_VERSION)
91
+ end
92
+
93
+ # Returns the bundle's release version number.
94
+ # @return [String, nil]
95
+ # @see INFO_KEY_BUNDLE_SHORT_VERSION_STRING
96
+ def release_version
97
+ info_string(CFBundle::INFO_KEY_BUNDLE_SHORT_VERSION_STRING)
98
+ end
99
+
100
+ # Returns the bundle's OS Type code.
101
+ #
102
+ # The value for this key consists of a four-letter code.
103
+ # @return [String, nil]
104
+ # @see INFO_KEY_BUNDLE_PACKAGE_TYPE
105
+ # @see PACKAGE_TYPE_APPLICATION
106
+ # @see PACKAGE_TYPE_BUNDLE
107
+ # @see PACKAGE_TYPE_FRAMEWORK
108
+ def package_type
109
+ info_string(CFBundle::INFO_KEY_BUNDLE_PACKAGE_TYPE)
110
+ end
111
+
112
+ # Returns the short name of the bundle.
113
+ # @return [String, nil]
114
+ # @see INFO_KEY_BUNDLE_NAME
115
+ def name
116
+ info_string(CFBundle::INFO_KEY_BUNDLE_NAME)
117
+ end
118
+
119
+ # Returns the user-visible name of the bundle.
120
+ # @return [String, nil]
121
+ # @see INFO_KEY_BUNDLE_DISPLAY_NAME
122
+ def display_name
123
+ info_string(CFBundle::INFO_KEY_BUNDLE_DISPLAY_NAME)
124
+ end
125
+
126
+ # Returns the name of the development language of the bundle.
127
+ # @return [String, nil]
128
+ # @see INFO_KEY_BUNDLE_DEVELOPMENT_REGION
129
+ def development_localization
130
+ info_string(CFBundle::INFO_KEY_BUNDLE_DEVELOPMENT_REGION)
131
+ end
132
+
133
+ # Returns the name of the bundle's executable file.
134
+ # @return [String, nil]
135
+ # @see INFO_KEY_BUNDLE_EXECUTABLE
136
+ def executable_name
137
+ info_string(CFBundle::INFO_KEY_BUNDLE_EXECUTABLE)
138
+ end
139
+
140
+ # Returns the path of the bundle's executable file.
141
+ #
142
+ # The executable's path is relative to the bundle's path.
143
+ # @return [String, nil]
144
+ # @see INFO_KEY_BUNDLE_EXECUTABLE
145
+ def executable_path
146
+ @executable_path ||=
147
+ executable_name && lookup_executable_path(executable_name)
148
+ end
149
+
150
+ # Returns the path of the bundle's subdirectory that contains its resources.
151
+ #
152
+ # The path is relative to the bundle's path. For iOS application bundles,
153
+ # as the resources directory is the bundle, this method returns a single dot
154
+ # (+.+).
155
+ # @return [String]
156
+ # @see Resource
157
+ def resources_directory
158
+ case layout_version
159
+ when 0 then 'Resources'
160
+ when 2 then 'Contents/Resources'
161
+ when 3 then '.'
162
+ end
163
+ end
164
+
165
+ # Returns a list of all the localizations contained in the bundle.
166
+ # @return [Array]
167
+ def localizations
168
+ @localizations ||= Localization.localizations_in(self)
169
+ end
170
+
171
+ # Returns an ordered list of preferred localizations contained in the
172
+ # bundle.
173
+ # @param preferred_languages [Array] An array of strings (or symbols)
174
+ # corresponding to a user's preferred languages.
175
+ # @return [Array]
176
+ def preferred_localizations(preferred_languages)
177
+ Localization.preferred_localizations(localizations, preferred_languages)
178
+ end
179
+
180
+ # Returns the first {Resource} object that matches the specified parameters.
181
+ #
182
+ # @param name [String?] The name to match or +nil+ to match any name.
183
+ # @param extension [String?] The extension to match or +nil+ to match any
184
+ # extension.
185
+ # @param subdirectory [String?] The name of the bundle subdirectory
186
+ # search.
187
+ # @param localization [String?, Symbol?] A language identifier to restrict
188
+ # the search to a specific localization.
189
+ # @param preferred_languages [Array] An array of strings (or symbols)
190
+ # corresponding to a user's preferred languages.
191
+ # @param product [String?] The product to match or +nil+ to match any
192
+ # product.
193
+ # @return [Resource?]
194
+ # @see Resource.foreach
195
+ def find_resource(name, extension: nil, subdirectory: nil,
196
+ localization: nil, preferred_languages: [], product: nil)
197
+ Resource.foreach(
198
+ self, name,
199
+ extension: extension, subdirectory: subdirectory,
200
+ localization: localization, preferred_languages: preferred_languages,
201
+ product: product
202
+ ).first
203
+ end
204
+
205
+ # Returns all the {Resource} objects that matches the specified parameters.
206
+ #
207
+ # @param name [String?] The name to match or +nil+ to match any name.
208
+ # @param extension [String?] The extension to match or +nil+ to match any
209
+ # extension.
210
+ # @param subdirectory [String?] The name of the bundle subdirectory to
211
+ # search.
212
+ # @param localization [String?, Array] The language identifier for the
213
+ # localization.
214
+ # @param preferred_languages [Array] An array of strings (or symbols)
215
+ # corresponding to a user's preferred languages.
216
+ # @param product [String?] The product to match or +nil+ to match any
217
+ # product.
218
+ # @return [Array] An array of {Resource} objects.
219
+ # @see Resource.foreach
220
+ def find_resources(name, extension: nil, subdirectory: nil,
221
+ localization: nil, preferred_languages: [], product: nil)
222
+ Resource.foreach(
223
+ self, name,
224
+ extension: extension, subdirectory: subdirectory,
225
+ localization: localization, preferred_languages: preferred_languages,
226
+ product: product
227
+ ).to_a
228
+ end
229
+
230
+ private
231
+
232
+ def layout_version
233
+ @layout_version ||= detect_layout_version
234
+ end
235
+
236
+ def detect_layout_version
237
+ return 2 if storage.directory? 'Contents'
238
+ return 0 if storage.directory? 'Resources'
239
+ 3
240
+ end
241
+
242
+ def info_string(key)
243
+ value = info[key.to_s]
244
+ value.to_s unless value.nil?
245
+ end
246
+
247
+ def lookup_executable_path(name)
248
+ root = layout_version == 2 ? 'Contents' : '.'
249
+ path = PathUtils.join(root, 'MacOS', name)
250
+ return path if storage.file? path
251
+ path = PathUtils.join(root, name)
252
+ return path if storage.file? path
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,14 @@
1
+ module CFBundle
2
+ INFO_KEY_BUNDLE_DEVELOPMENT_REGION = 'CFBundleDevelopmentRegion'.freeze
3
+ INFO_KEY_BUNDLE_DISPLAY_NAME = 'CFBundleDisplayName'.freeze
4
+ INFO_KEY_BUNDLE_EXECUTABLE = 'CFBundleExecutable'.freeze
5
+ INFO_KEY_BUNDLE_IDENTIFIER = 'CFBundleIdentifier'.freeze
6
+ INFO_KEY_BUNDLE_NAME = 'CFBundleName'.freeze
7
+ INFO_KEY_BUNDLE_PACKAGE_TYPE = 'CFBundlePackageType'.freeze
8
+ INFO_KEY_BUNDLE_SHORT_VERSION_STRING = 'CFBundleShortVersionString'.freeze
9
+ INFO_KEY_BUNDLE_VERSION = 'CFBundleVersion'.freeze
10
+
11
+ PACKAGE_TYPE_APPLICATION = 'APPL'.freeze
12
+ PACKAGE_TYPE_BUNDLE = 'BNDL'.freeze
13
+ PACKAGE_TYPE_FRAMEWORK = 'FMWK'.freeze
14
+ end
@@ -0,0 +1,66 @@
1
+ module CFBundle
2
+ # @private
3
+ #
4
+ # Utility methods to perform localization.
5
+ module Localization
6
+ # The file extension of localization directories.
7
+ FILE_EXTENSION = '.lproj'.freeze
8
+
9
+ class << self
10
+ # Returns all the localizations contained in a bundle.
11
+ # @param bundle [Bundle] The bundle to search.
12
+ # @return [Array]
13
+ def localizations_in(bundle)
14
+ return [] unless bundle.storage.directory?(bundle.resources_directory)
15
+ bundle.storage
16
+ .foreach(bundle.resources_directory)
17
+ .select { |path| File.extname(path) == FILE_EXTENSION }
18
+ .map { |path| File.basename(path, FILE_EXTENSION) }
19
+ end
20
+
21
+ # Returns an ordered list of preferred localizations contained in a
22
+ # bundle.
23
+ # @param localizations [Array] An array of localization identifiers.
24
+ # @param preferred_languages [Array] An array of strings (or symbols)
25
+ # corresponding to a user's preferred languages.
26
+ # @return [Array]
27
+ # @see Bundle#localizations
28
+ def preferred_localizations(localizations, preferred_languages)
29
+ preferred_languages.each do |language|
30
+ result = matching_localizations(localizations, language)
31
+ return result unless result.empty?
32
+ result = alternate_regional_localizations(localizations, language)
33
+ return result unless result.empty?
34
+ end
35
+ []
36
+ end
37
+
38
+ private
39
+
40
+ def matching_localizations(localizations, language)
41
+ result = []
42
+ loop do
43
+ if localizations.include?(language.to_s) ||
44
+ localizations.include?(language.to_sym)
45
+ result << language
46
+ end
47
+ language = language.to_s.rpartition('-').first
48
+ break if language.empty?
49
+ end
50
+ result
51
+ end
52
+
53
+ def alternate_regional_localizations(localizations, language)
54
+ loop do
55
+ language = language.to_s.rpartition('-').first
56
+ return [] if language.empty?
57
+ prefix = language + '-'
58
+ match = localizations.find do |localization|
59
+ localization.start_with?(prefix)
60
+ end
61
+ return [match.to_s] if match
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ module CFBundle
2
+ # @private
3
+ #
4
+ # Utility methods for manipulating paths
5
+ module PathUtils
6
+ class << self
7
+ # Returns a new path formed by joining the strings using
8
+ # +File::SEPARATOR+.
9
+ #
10
+ # The methods also makes sure to remove any trailing separator along with
11
+ # path components that are empty, nil or a single dot (+.+) in order to
12
+ # generate a canonical path.
13
+ #
14
+ # @param args [Array] An array of strings.
15
+ # @return [String]
16
+ def join(*args)
17
+ args.compact!
18
+ args.map! { |arg| arg.split(File::SEPARATOR) }
19
+ args.flatten!
20
+ args.reject! { |arg| arg == '.' }
21
+ return '.' if args.empty?
22
+ absolute = args.first == ''
23
+ args.reject! { |arg| arg == '' }
24
+ args.unshift('/') if absolute
25
+ File.join(args)
26
+ end
27
+
28
+ # Splits the resource path into four components.
29
+ #
30
+ # The components are the resource's directory, name, product and
31
+ # extension. The product is either empty or starts with a tilde. The
32
+ # extension is either empty or starts with a dot.
33
+ # @param path [String] The path to the resource.
34
+ # @return [Array]
35
+ # @see join_resource
36
+ def split_resource(path)
37
+ directory = File.dirname(path)
38
+ extension = File.extname(path)
39
+ basename = File.basename(path, extension)
40
+ name, product = split_resource_name_and_product(basename)
41
+ [directory, name, product, extension]
42
+ end
43
+
44
+ # Returns a new path formed by joining the resource components.
45
+ # @param directory [String] The resource's directory.
46
+ # @param name [String] The resource's name.
47
+ # @param product [String] The resource's product. It should be empty or
48
+ # start with a tilde.
49
+ # @param extension [String] The resource's extension. It should be empty
50
+ # or start with a dot.
51
+ # @return [String]
52
+ # @see split_resource
53
+ def join_resource(directory, name, product, extension)
54
+ filename = [name, product, extension].join
55
+ join(directory, filename)
56
+ end
57
+
58
+ private
59
+
60
+ def split_resource_name_and_product(basename)
61
+ name, _, product = basename.rpartition('~')
62
+ if name.empty? || product.empty?
63
+ [basename, '']
64
+ else
65
+ [name, '~' + product]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,37 @@
1
+ require 'cfpropertylist'
2
+
3
+ module CFBundle
4
+ # @private
5
+ #
6
+ # Utility methods for manipulating Property list files.
7
+ module Plist
8
+ class << self
9
+ # Loads the +Info.plist+ file in a bundle.
10
+ # @param bundle [Bundle] The bundle to search.
11
+ # @return [Hash]
12
+ def load_info_plist(bundle)
13
+ load_plist(bundle, info_plist_path_in(bundle))
14
+ end
15
+
16
+ # Loads a Propery list file in a bundle.
17
+ # @param bundle [Bundle] The bundle to search.
18
+ # @param path [String] The path to the Property list file in the bundle.
19
+ # @return [Hash]
20
+ def load_plist(bundle, path)
21
+ data = bundle.storage.open(path, &:read)
22
+ plist = CFPropertyList::List.new(data: data)
23
+ CFPropertyList.native_types(plist.value)
24
+ end
25
+
26
+ private
27
+
28
+ def info_plist_path_in(bundle)
29
+ case bundle.send :layout_version
30
+ when 0 then 'Resources/Info.plist'
31
+ when 2 then 'Contents/Info.plist'
32
+ when 3 then 'Info.plist'
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,202 @@
1
+ require 'cfbundle/path_utils'
2
+
3
+ module CFBundle
4
+ # A {Resource} is an abstraction of file contained within a {Bundle}.
5
+ class Resource
6
+ # Returns the resource's enclosing bundle.
7
+ # @return [Bundle]
8
+ attr_reader :bundle
9
+
10
+ # Returns the path to the resource within the bundle.
11
+ # @return [String]
12
+ attr_reader :path
13
+
14
+ # @param bundle [Bundle] The resource's enclosing bundle.
15
+ # @param path [String] The path of the resource within the bundle.
16
+ def initialize(bundle, path)
17
+ @bundle = bundle
18
+ @path = path
19
+ @directory, @name, @product, @extension = PathUtils.split_resource(path)
20
+ end
21
+
22
+ # Opens the resource for reading.
23
+ #
24
+ # With no associated block, the method returns an IO. If the optional block
25
+ # is given, it will be passed the opened file and the file will
26
+ # automatically be closed when the block terminates.
27
+ # @yieldparam file [IO] The opened file. It is automatically closed when the
28
+ # block terminates.
29
+ # @return [Object] The return value to the block when a block is given.
30
+ # @return [IO] The opened file. When no block is given.
31
+ def open(&block)
32
+ bundle.storage.open(path, &block)
33
+ end
34
+
35
+ # Enumerates the resources in a bundle that match the specified parameters.
36
+ #
37
+ # @param bundle [Bundle] The bundle that contains the resources.
38
+ # @param name [String?] The name to match or +nil+ to match any name.
39
+ # @param extension [String?] The extension to match or +nil+ to match any
40
+ # extension.
41
+ # @param localization [String?, Symbol?] A language identifier to restrict
42
+ # the search to a specific localization.
43
+ # @param preferred_languages [Array] An array of strings (or symbols)
44
+ # corresponding to a user's preferred languages.
45
+ # @param product [String?] The product to match or +nil+ to match any
46
+ # product.
47
+ # @yieldparam resource [Resource]
48
+ # @return [nil] When a block is given.
49
+ # @return [Enumerator] When no block is given.
50
+ def self.foreach(bundle, name, extension: nil, subdirectory: nil,
51
+ localization: nil, preferred_languages: [], product: nil,
52
+ &block)
53
+ enumerator = ::Enumerator.new do |y|
54
+ enumerator = Enumerator.new(bundle, subdirectory, localization,
55
+ preferred_languages)
56
+ predicate = Predicate.new(name, extension, product)
57
+ loop do
58
+ resource = enumerator.next
59
+ y << resource if resource.send(:match?, predicate)
60
+ end
61
+ end
62
+ enumerator.each(&block)
63
+ end
64
+
65
+ private
66
+
67
+ def match?(predicate)
68
+ return false unless name_match?(predicate) &&
69
+ extension_match?(predicate) &&
70
+ product_match?(predicate)
71
+ predicate.uniq?(@name, @extension)
72
+ end
73
+
74
+ def name_match?(predicate)
75
+ predicate.name.nil? || @name == predicate.name
76
+ end
77
+
78
+ def extension_match?(predicate)
79
+ predicate.extension.nil? || @extension == predicate.extension
80
+ end
81
+
82
+ def product_match?(predicate)
83
+ return true if @product == predicate.product
84
+ return false unless @product.empty?
85
+ !bundle.storage.exist?(path_with_product(predicate.product))
86
+ end
87
+
88
+ def path_with_product(product)
89
+ PathUtils.join_resource(@directory, @name, product, @extension)
90
+ end
91
+
92
+ # @private
93
+ #
94
+ # Performs the enumeration of a bundle's resources.
95
+ class Enumerator
96
+ # @param bundle [Bundle] The bundle that contains the resources.
97
+ # @param subdirectory [String?] The name of the bundle subdirectory to
98
+ # search.
99
+ # @param localization [String?, Symbol?] A language identifier to restrict
100
+ # the search to a specific localization.
101
+ # @param preferred_languages [Array] An array of strings (or symbols)
102
+ # corresponding to a user's preferred languages.
103
+ def initialize(bundle, subdirectory, localization, preferred_languages)
104
+ @bundle = bundle
105
+ @directory = PathUtils.join(bundle.resources_directory, subdirectory)
106
+ @localizations = localizations_for(bundle, localization,
107
+ preferred_languages)
108
+ @enumerator = [].to_enum
109
+ end
110
+
111
+ # Returns the next resource in the bundle.
112
+ #
113
+ # @return [Resource]
114
+ # @raise [StopIteration]
115
+ def next
116
+ Resource.new(@bundle, @enumerator.next)
117
+ rescue StopIteration
118
+ @enumerator = next_enumerator
119
+ retry
120
+ end
121
+
122
+ private
123
+
124
+ def next_enumerator
125
+ loop do
126
+ break if @localizations.empty?
127
+ localization = @localizations.shift
128
+ directory = localized_directory_for(localization)
129
+ next unless @bundle.storage.directory?(directory)
130
+ return @bundle.storage.foreach(directory)
131
+ end
132
+ raise StopIteration
133
+ end
134
+
135
+ def localized_directory_for(localization)
136
+ return @directory unless localization
137
+ PathUtils.join(@directory, localization + '.lproj')
138
+ end
139
+
140
+ def localizations_for(bundle, localization, preferred_languages)
141
+ return [nil, localization.to_s] if localization
142
+ [
143
+ nil,
144
+ *bundle.preferred_localizations(preferred_languages || []),
145
+ bundle.development_localization
146
+ ].uniq
147
+ end
148
+ end
149
+
150
+ # @private
151
+ #
152
+ # Stores the parameters to select the matching resources while enumerating
153
+ # the resources of a bundle.
154
+ class Predicate
155
+ # Returns the name to match or +nil+ to match any name.
156
+ # @return [String?]
157
+ attr_reader :name
158
+
159
+ # Returns the extension to match or +nil+ to match any extension.
160
+ # @return [String?]
161
+ attr_reader :extension
162
+
163
+ # Returns the product to match.
164
+ # @return [String]
165
+ attr_reader :product
166
+
167
+ # @param name [String?] The name to match or +nil+ to match any name.
168
+ # @param extension [String?] The extension to match or +nil+ to match any
169
+ # extension.
170
+ # @param product [String?] The product to match.
171
+ def initialize(name, extension, product)
172
+ @name = name
173
+ @extension = extension_for(extension)
174
+ @product = product_for(product)
175
+ @keys = Set.new
176
+ end
177
+
178
+ # Ensures the given name and extension are unique during the enumeration.
179
+ #
180
+ # @param name [String] The resource name.
181
+ # @param extension [String] The resource extension.
182
+ def uniq?(name, extension)
183
+ key = [name, extension].join
184
+ return false if @keys.include?(key)
185
+ @keys << key
186
+ true
187
+ end
188
+
189
+ private
190
+
191
+ def extension_for(extension)
192
+ return extension if extension.nil? || extension.empty?
193
+ extension.start_with?('.') ? extension : '.' + extension
194
+ end
195
+
196
+ def product_for(product)
197
+ return '' if product.nil? || product.empty?
198
+ product.start_with?('~') ? product : '~' + product
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,69 @@
1
+ module CFBundle
2
+ module Storage
3
+ # The {Storage::Base} class defines the methods required to access a
4
+ # bundle's underlying storage.
5
+ #
6
+ # Most of the time, you don't need to concern youself with storages as
7
+ # {CFBundle::Bundle.open} and {CFBundle::Bundle#initialize}
8
+ # automatically detect and instantiate the appropriate storage.
9
+ class Base
10
+ # Returns whether a given path exists within the storage.
11
+ #
12
+ # @param path [String] The path of a file or directory, relative to the
13
+ # storage.
14
+ def exist?(path)
15
+ # :nocov:
16
+ false
17
+ # :nocov:
18
+ end
19
+
20
+ # Returns whether a given file exists within the storage.
21
+ #
22
+ # @param path [String] The path of a file, relative to the storage.
23
+ def file?(path)
24
+ # :nocov:
25
+ false
26
+ # :nocov:
27
+ end
28
+
29
+ # Returns whether a given directory exists within the storage.
30
+ #
31
+ # @param path [String] The path of a directory, relative to the storage.
32
+ def directory?(path)
33
+ # :nocov:
34
+ false
35
+ # :nocov:
36
+ end
37
+
38
+ # Opens a file for reading in the storage.
39
+ #
40
+ # @param path [String] The path of the file to open.
41
+ # @yieldparam file [IO] The opened file. It is automatically closed when
42
+ # the block terminates.
43
+ # @return [Object] The return value of the block when a block if given.
44
+ # @return [IO] The opened file when no block is given.
45
+ def open(path, &block)
46
+ # :nocov:
47
+ raise(Errno::ENOENT, path)
48
+ # :nocov:
49
+ end
50
+
51
+ # Returns an enumerator that enumerates the files contained in a
52
+ # directory.
53
+ #
54
+ # @param path [String] The path to the directory to enumerate.
55
+ # @return [Enumerator]
56
+ def foreach(path)
57
+ # :nocov:
58
+ raise(Errno::ENOENT, path)
59
+ # :nocov:
60
+ end
61
+
62
+ # Invoked when the storage is no longer needed.
63
+ #
64
+ # The default implementation does nothing.
65
+ # @return [void]
66
+ def close; end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ require 'cfbundle/path_utils'
2
+ require 'cfbundle/storage/base'
3
+
4
+ module CFBundle
5
+ module Storage
6
+ # A bundle storage that reads from the file system.
7
+ class FileSystem < Base
8
+ # @param path [String] The path of the bundle on the file system.
9
+ def initialize(path)
10
+ @root = path
11
+ end
12
+
13
+ # (see Base#exist?)
14
+ def exist?(path)
15
+ find(path) != nil
16
+ end
17
+
18
+ # (see Base#file?)
19
+ def file?(path)
20
+ entry = find(path)
21
+ !entry.nil? && File.file?(entry)
22
+ end
23
+
24
+ # (see Base#directory?)
25
+ def directory?(path)
26
+ entry = find(path)
27
+ !entry.nil? && File.directory?(entry)
28
+ end
29
+
30
+ # (see Base#open)
31
+ def open(path, &block)
32
+ File.open find!(path), &block
33
+ end
34
+
35
+ # (see Base#foreach)
36
+ def foreach(path)
37
+ Enumerator.new do |y|
38
+ base = Dir.foreach find!(path)
39
+ loop do
40
+ entry = base.next
41
+ y << PathUtils.join(path, entry) unless ['.', '..'].include?(entry)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def find(path)
49
+ entry = PathUtils.join(@root, path)
50
+ entry if File.exist? entry
51
+ end
52
+
53
+ def find!(path)
54
+ find(path) || raise(Errno::ENOENT, path)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,96 @@
1
+ require 'cfbundle/path_utils'
2
+ require 'cfbundle/storage/base'
3
+
4
+ module CFBundle
5
+ module Storage
6
+ # A bundle storage that reads from a ZIP archive.
7
+ class Zip < Base
8
+ # @param zip [Zip::File] The Zip file containing the bundle.
9
+ # @param path [String] The path of the bundle within the Zip file.
10
+ # @param skip_close [Boolean] Whether the storage should skip closing
11
+ # the Zip file when receiving {#close}.
12
+ def initialize(zip, path, skip_close: false)
13
+ @zip = zip
14
+ @root = path
15
+ @skip_close = skip_close
16
+ end
17
+
18
+ # (see Base#exist?)
19
+ def exist?(path)
20
+ find(path) != nil
21
+ end
22
+
23
+ # (see Base#file?)
24
+ def file?(path)
25
+ entry = find(path)
26
+ !entry.nil? && entry.file?
27
+ end
28
+
29
+ # (see Base#directory?)
30
+ def directory?(path)
31
+ entry = find(path)
32
+ !entry.nil? && entry.directory?
33
+ end
34
+
35
+ # (see Base#open)
36
+ def open(path, &block)
37
+ find!(path).get_input_stream(&block)
38
+ end
39
+
40
+ # (see Base#foreach)
41
+ def foreach(path)
42
+ Enumerator.new do |y|
43
+ directory = find! path
44
+ base = @zip.entries.each
45
+ loop do
46
+ entry = base.next
47
+ next unless entry.parent_as_string == directory.name
48
+ y << PathUtils.join(path, File.basename(entry.name))
49
+ end
50
+ end
51
+ end
52
+
53
+ # Invoked when the storage is no longer needed.
54
+ #
55
+ # This method closes the underlying Zip file unless the storage was
56
+ # initialized with +skip_close: true+.
57
+ # @return [void]
58
+ def close
59
+ @zip.close unless @skip_close
60
+ end
61
+
62
+ private
63
+
64
+ def find(path, symlinks = Set.new)
65
+ name = PathUtils.join(@root, path)
66
+ entry = @zip.find_entry name
67
+ if entry.nil?
68
+ find_in_parent(path, symlinks)
69
+ elsif entry.symlink?
70
+ find_symlink(entry.get_input_stream(&:read), path, symlinks)
71
+ else
72
+ entry
73
+ end
74
+ end
75
+
76
+ def find!(path)
77
+ find(path) || raise(Errno::ENOENT, path)
78
+ end
79
+
80
+ def find_in_parent(path, symlinks)
81
+ directory, filename = File.split(path)
82
+ return if ['.', '/'].include? filename
83
+ entry = find(directory, symlinks)
84
+ return unless entry && entry.directory?
85
+ @zip.find_entry File.join(entry.name, filename)
86
+ end
87
+
88
+ def find_symlink(path, symlink, symlinks)
89
+ return if path.start_with?('/') || symlinks.include?(symlink)
90
+ symlinks << symlink
91
+ resolved_path = PathUtils.join(File.dirname(symlink), path)
92
+ find(resolved_path, symlinks)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,86 @@
1
+ require 'cfbundle/storage/base'
2
+ require 'cfbundle/storage/file_system'
3
+ require 'cfbundle/storage/zip'
4
+
5
+ module CFBundle
6
+ # @private
7
+ #
8
+ # Utility methods required to detect and instantiate a bundle's storage.
9
+ module StorageDetection
10
+ class << self
11
+ # Opens a file and returns a bundle storage.
12
+ # @param file The file to open.
13
+ # @return [Storage]
14
+ def open(file)
15
+ storage = open_as_storage(file) ||
16
+ open_as_io(file) ||
17
+ open_as_path(file)
18
+ raise "#{file.inspect} is not a bundle" unless storage
19
+ storage
20
+ end
21
+
22
+ private
23
+
24
+ def path_for(file)
25
+ if file.is_a? String
26
+ file
27
+ elsif file.respond_to? :to_path
28
+ file.to_path
29
+ end
30
+ end
31
+
32
+ def open_as_storage(file)
33
+ file if file.is_a?(Storage::Base)
34
+ end
35
+
36
+ def open_as_path(file)
37
+ path = path_for(file) || return
38
+ ext = File.extname(path)
39
+ if File.directory?(path) && ext != ''
40
+ Storage::FileSystem.new(path)
41
+ elsif ['.ipa', '.zip'].include? ext
42
+ open_zip_path(path)
43
+ end
44
+ end
45
+
46
+ def open_as_io(file)
47
+ open_zip_io(file.to_io) if file.respond_to?(:to_io)
48
+ end
49
+
50
+ def open_zip_path(path)
51
+ open_zip_file(path, &:open)
52
+ end
53
+
54
+ def open_zip_io(io)
55
+ open_zip_file(io, &:open_buffer)
56
+ end
57
+
58
+ def open_zip_file(file, skip_close: false)
59
+ unless defined?(Zip)
60
+ raise "cannot open ZIP archive #{file.inspect} without Rubyzip"
61
+ end
62
+ zip = yield(Zip::File, file)
63
+ entry = matching_zip_entry(zip)
64
+ Storage::Zip.new(zip, entry.name, skip_close: skip_close)
65
+ end
66
+
67
+ def matching_zip_entry(zip)
68
+ entries = zip.entries.select { |entry| zip_entry_match?(entry) }
69
+ case entries.count
70
+ when 1
71
+ entries.first
72
+ when 0
73
+ raise "no bundle found in ZIP archive \"#{zip}\""
74
+ else
75
+ raise "several bundles found in ZIP archive \"#{zip}\""
76
+ end
77
+ end
78
+
79
+ def zip_entry_match?(entry)
80
+ entry.directory? &&
81
+ [nil, 'Payload/'].include?(entry.parent_as_string) &&
82
+ File.extname(entry.name) != ''
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,3 @@
1
+ module CFBundle
2
+ VERSION = '0.1.0'.freeze
3
+ end
data/lib/cfbundle.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'cfbundle/bundle'
2
+ require 'cfbundle/constants'
3
+ require 'cfbundle/storage/file_system'
4
+ require 'cfbundle/storage/zip'
5
+ require 'cfbundle/version'
6
+
7
+ # Module for reading macOS and iOS bundles.
8
+ #
9
+ # See {CFBundle::Bundle.open} for opening bundles.
10
+ module CFBundle
11
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cfbundle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolas Bachschmidt
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-11-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: CFPropertyList
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.3.5
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 3.0.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 2.3.5
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 3.0.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: rspec
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 3.7.0
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 3.7.0
47
+ - !ruby/object:Gem::Dependency
48
+ name: rubocop
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.51.0
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 0.51.0
61
+ - !ruby/object:Gem::Dependency
62
+ name: rubyzip
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 1.2.1
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 1.2.1
75
+ - !ruby/object:Gem::Dependency
76
+ name: simplecov
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.15.1
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.15.1
89
+ description: CFBundle is a module for reading macOS and iOS bundles (including zipped
90
+ bundles and .ipa files).
91
+ email: nicolas@h2g.io
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - lib/cfbundle.rb
97
+ - lib/cfbundle/bundle.rb
98
+ - lib/cfbundle/constants.rb
99
+ - lib/cfbundle/localization.rb
100
+ - lib/cfbundle/path_utils.rb
101
+ - lib/cfbundle/plist.rb
102
+ - lib/cfbundle/resource.rb
103
+ - lib/cfbundle/storage/base.rb
104
+ - lib/cfbundle/storage/file_system.rb
105
+ - lib/cfbundle/storage/zip.rb
106
+ - lib/cfbundle/storage_detection.rb
107
+ - lib/cfbundle/version.rb
108
+ homepage:
109
+ licenses:
110
+ - MIT
111
+ metadata: {}
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubyforge_project:
128
+ rubygems_version: 2.6.11
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: CFBundle
132
+ test_files: []