roku_builder 3.3.2
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 +4 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +101 -0
- data/Guardfile +21 -0
- data/LICENSE.txt +22 -0
- data/README.md +282 -0
- data/bin/roku +152 -0
- data/config.json.example +28 -0
- data/lib/roku_builder.rb +32 -0
- data/lib/roku_builder/config_manager.rb +157 -0
- data/lib/roku_builder/controller.rb +582 -0
- data/lib/roku_builder/inspector.rb +90 -0
- data/lib/roku_builder/keyer.rb +52 -0
- data/lib/roku_builder/linker.rb +46 -0
- data/lib/roku_builder/loader.rb +197 -0
- data/lib/roku_builder/manifest_manager.rb +63 -0
- data/lib/roku_builder/monitor.rb +62 -0
- data/lib/roku_builder/navigator.rb +107 -0
- data/lib/roku_builder/packager.rb +47 -0
- data/lib/roku_builder/tester.rb +32 -0
- data/lib/roku_builder/util.rb +31 -0
- data/lib/roku_builder/version.rb +4 -0
- data/rakefile +8 -0
- data/roku_builder.gemspec +36 -0
- data/tests/roku_builder/config_manager_test.rb +400 -0
- data/tests/roku_builder/controller_test.rb +250 -0
- data/tests/roku_builder/inspector_test.rb +153 -0
- data/tests/roku_builder/keyer_test.rb +88 -0
- data/tests/roku_builder/linker_test.rb +37 -0
- data/tests/roku_builder/loader_test.rb +153 -0
- data/tests/roku_builder/manifest_manager_test.rb +25 -0
- data/tests/roku_builder/monitor_test.rb +34 -0
- data/tests/roku_builder/navigator_test.rb +72 -0
- data/tests/roku_builder/packager_test.rb +125 -0
- data/tests/roku_builder/test_files/controller_test/load_config_test.json +28 -0
- data/tests/roku_builder/test_files/controller_test/valid_config.json +28 -0
- data/tests/roku_builder/test_files/loader_test/c +0 -0
- data/tests/roku_builder/test_files/loader_test/manifest +0 -0
- data/tests/roku_builder/test_files/loader_test/source/a +0 -0
- data/tests/roku_builder/test_files/loader_test/source/b +0 -0
- data/tests/roku_builder/test_files/manifest_manager_test/manifest_template +2 -0
- data/tests/roku_builder/test_helper.rb +6 -0
- data/tests/roku_builder/tester_test.rb +33 -0
- data/tests/roku_builder/util_test.rb +23 -0
- metadata +286 -0
@@ -0,0 +1,90 @@
|
|
1
|
+
module RokuBuilder
|
2
|
+
|
3
|
+
# Collects information on a package for submission
|
4
|
+
class Inspector < Util
|
5
|
+
|
6
|
+
# Inspects the given pkg
|
7
|
+
# @param pkg [String] Path to the pkg to be inspected
|
8
|
+
# @param password [String] Password for the given pkg
|
9
|
+
# @return [Hash] Package information. Contains the following keys:
|
10
|
+
# * app_name
|
11
|
+
# * dev_id
|
12
|
+
# * creation_date
|
13
|
+
# * dev_zip
|
14
|
+
def inspect(pkg:, password:)
|
15
|
+
|
16
|
+
# upload new key with password
|
17
|
+
path = "/plugin_inspect"
|
18
|
+
conn = Faraday.new(url: @url) do |f|
|
19
|
+
f.request :digest, @dev_username, @dev_password
|
20
|
+
f.request :multipart
|
21
|
+
f.request :url_encoded
|
22
|
+
f.adapter Faraday.default_adapter
|
23
|
+
end
|
24
|
+
payload = {
|
25
|
+
mysubmit: "Inspect",
|
26
|
+
passwd: password,
|
27
|
+
archive: Faraday::UploadIO.new(pkg, 'application/octet-stream')
|
28
|
+
}
|
29
|
+
response = conn.post path, payload
|
30
|
+
|
31
|
+
app_name = /App Name:\s*<\/td>\s*<td>\s*<font[^>]*>([^<]*)<\/font>\s*<\/td>/.match(response.body)
|
32
|
+
dev_id = nil
|
33
|
+
creation_date = nil
|
34
|
+
dev_zip = nil
|
35
|
+
if app_name
|
36
|
+
app_name = app_name[1]
|
37
|
+
dev_id = /Dev ID:\s*<\/td>\s*<td>\s*<font[^>]*>([^<]*)<\/font>\s*<\/td>/.match(response.body)[1]
|
38
|
+
creation_date = /Creation Date:\s*<\/td>\s*<td>\s*<font[^>]*>\s*<script[^>]*>\s*var d = new Date\(([^\)]*)\)[^<]*<\/script><\/font>\s*<\/td>/.match(response.body.gsub("\n", ''))[1]
|
39
|
+
dev_zip = /dev.zip:\s*<\/td>\s*<td>\s*<font[^>]*>([^<]*)<\/font>\s*<\/td>/.match(response.body)[1]
|
40
|
+
else
|
41
|
+
app_name = /App Name:[^<]*<div[^>]*>([^<]*)<\/div>/.match(response.body)[1]
|
42
|
+
dev_id = /Dev ID:[^<]*<div[^>]*><font[^>]*>([^<]*)<\/font><\/div>/.match(response.body)[1]
|
43
|
+
creation_date = /new Date\(([^\/]*)\)/.match(response.body.gsub("\n", ''))[1]
|
44
|
+
dev_zip = /dev.zip:[^<]*<div[^>]*><font[^>]*>([^<]*)<\/font><\/div>/.match(response.body)[1]
|
45
|
+
end
|
46
|
+
|
47
|
+
return {app_name: app_name, dev_id: dev_id, creation_date: Time.at(creation_date.to_i).to_s, dev_zip: dev_zip}
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
# Capture a screencapture for the currently sideloaded app
|
52
|
+
# @return [Boolean] Success
|
53
|
+
def screencapture(out_folder:, out_file: nil)
|
54
|
+
path = "/plugin_inspect"
|
55
|
+
conn = Faraday.new(url: @url) do |f|
|
56
|
+
f.request :digest, @dev_username, @dev_password
|
57
|
+
f.request :multipart
|
58
|
+
f.request :url_encoded
|
59
|
+
f.adapter Faraday.default_adapter
|
60
|
+
end
|
61
|
+
payload = {
|
62
|
+
mysubmit: "Screenshot",
|
63
|
+
passwd: @dev_password,
|
64
|
+
archive: Faraday::UploadIO.new("/dev/null", 'application/octet-stream')
|
65
|
+
}
|
66
|
+
response = conn.post path, payload
|
67
|
+
|
68
|
+
path = /<img src="([^"]*)">/.match(response.body)
|
69
|
+
return false unless path
|
70
|
+
path = path[1]
|
71
|
+
unless out_file
|
72
|
+
out_file = /time=([^"]*)">/.match(response.body)
|
73
|
+
out_file = "dev_#{out_file[1]}.jpg" if out_file
|
74
|
+
end
|
75
|
+
|
76
|
+
conn = Faraday.new(url: @url) do |f|
|
77
|
+
f.request :digest, @dev_username, @dev_password
|
78
|
+
f.adapter Faraday.default_adapter
|
79
|
+
end
|
80
|
+
|
81
|
+
response = conn.get path
|
82
|
+
|
83
|
+
File.open(File.join(out_folder, out_file), "w") do |io|
|
84
|
+
io.write(response.body)
|
85
|
+
end
|
86
|
+
@logger.info "Screen captured to #{File.join(out_folder, out_file)}"
|
87
|
+
return response.success?
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module RokuBuilder
|
2
|
+
|
3
|
+
# Change or get dev key
|
4
|
+
class Keyer < Util
|
5
|
+
|
6
|
+
# Sets the key on the roku device
|
7
|
+
# @param keyed_pkg [String] Path for a package signed with the desired key
|
8
|
+
# @param password [String] Password for the package
|
9
|
+
# @return [Boolean] True if key changed, false otherwise
|
10
|
+
def rekey(keyed_pkg:, password:)
|
11
|
+
oldId = dev_id
|
12
|
+
|
13
|
+
# upload new key with password
|
14
|
+
path = "/plugin_inspect"
|
15
|
+
conn = Faraday.new(url: @url) do |f|
|
16
|
+
f.request :digest, @dev_username, @dev_password
|
17
|
+
f.request :multipart
|
18
|
+
f.request :url_encoded
|
19
|
+
f.adapter Faraday.default_adapter
|
20
|
+
end
|
21
|
+
payload = {
|
22
|
+
mysubmit: "Rekey",
|
23
|
+
passwd: password,
|
24
|
+
archive: Faraday::UploadIO.new(keyed_pkg, 'application/octet-stream')
|
25
|
+
}
|
26
|
+
response = conn.post path, payload
|
27
|
+
|
28
|
+
# check key
|
29
|
+
newId = dev_id
|
30
|
+
newId != oldId
|
31
|
+
end
|
32
|
+
|
33
|
+
# Get the current dev id
|
34
|
+
# @return [String] The current dev id
|
35
|
+
def dev_id
|
36
|
+
path = "/plugin_package"
|
37
|
+
conn = Faraday.new(url: @url) do |f|
|
38
|
+
f.request :digest, @dev_username, @dev_password
|
39
|
+
f.adapter Faraday.default_adapter
|
40
|
+
end
|
41
|
+
response = conn.get path
|
42
|
+
|
43
|
+
dev_id = /Your Dev ID:\s*<font[^>]*>([^<]*)<\/font>/.match(response.body)
|
44
|
+
if dev_id
|
45
|
+
dev_id = dev_id[1]
|
46
|
+
else
|
47
|
+
dev_id = /Your Dev ID:[^>]*<\/label> ([^<]*)/.match(response.body)[1]
|
48
|
+
end
|
49
|
+
dev_id
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module RokuBuilder
|
2
|
+
|
3
|
+
# Launch application, sending parameters
|
4
|
+
class Linker < Util
|
5
|
+
# Deeplink to the currently sideloaded app
|
6
|
+
# @param options [String] Options string
|
7
|
+
# @note Options string should be formated like the following: "<key>:<value>[, <key>:<value>]*"
|
8
|
+
# @note Any options will be accepted and sent to the app
|
9
|
+
def link(options:)
|
10
|
+
path = "/launch/dev"
|
11
|
+
payload = {}
|
12
|
+
return false unless options
|
13
|
+
opts = options.split(/,\s*/)
|
14
|
+
opts.each do |opt|
|
15
|
+
opt = opt.split(":")
|
16
|
+
key = opt.shift.to_sym
|
17
|
+
value = opt.join(":")
|
18
|
+
payload[key] = value
|
19
|
+
end
|
20
|
+
|
21
|
+
unless payload.keys.count > 0
|
22
|
+
return false
|
23
|
+
end
|
24
|
+
|
25
|
+
path = "#{path}?#{parameterize(payload)}"
|
26
|
+
conn = Faraday.new(url: "#{@url}:8060") do |f|
|
27
|
+
f.request :digest, @dev_username, @dev_password
|
28
|
+
f.request :multipart
|
29
|
+
f.request :url_encoded
|
30
|
+
f.adapter Faraday.default_adapter
|
31
|
+
end
|
32
|
+
|
33
|
+
response = conn.post path
|
34
|
+
return response.success?
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
# Parameterize options to be sent to the app
|
40
|
+
# @param params [Hash] Parameters to be sent
|
41
|
+
# @return [String] Parameters as a string, URI escaped
|
42
|
+
def parameterize(params)
|
43
|
+
URI.escape(params.collect{|k,v| "#{k}=#{URI.escape(v, "?&")}"}.join('&'))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
module RokuBuilder
|
2
|
+
|
3
|
+
# Load/Unload/Build roku applications
|
4
|
+
class Loader < Util
|
5
|
+
|
6
|
+
# Sideload an app onto a roku device
|
7
|
+
# @param root_dir [String] Path to the root directory of the roku app
|
8
|
+
# @param branch [String] Branch of the git repository to sideload. Pass nil to use working directory. Default: nil
|
9
|
+
# @param update_manifest [Boolean] Flag to update the manifest file before sideloading. Default: false
|
10
|
+
# @param fetch [Boolean] Flag to fetch all remotes before sideloading. Default: false
|
11
|
+
# @param folders [Array<String>] Array of folders to be sideloaded. Pass nil to send all folders. Default: nil
|
12
|
+
# @param files [Array<String>] Array of files to be sideloaded. Pass nil to send all files. Default: nil
|
13
|
+
# @return [String] Build version on success, nil otherwise
|
14
|
+
def sideload(root_dir:, branch: nil, update_manifest: false, fetch: false, folders: nil, files: nil)
|
15
|
+
@root_dir = root_dir
|
16
|
+
result = nil
|
17
|
+
stash = nil
|
18
|
+
if branch
|
19
|
+
git = Git.open(@root_dir)
|
20
|
+
if fetch
|
21
|
+
for remote in git.remotes
|
22
|
+
git.fetch(remote)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
current_dir = Dir.pwd
|
27
|
+
begin
|
28
|
+
if git and branch and branch != git.current_branch
|
29
|
+
Dir.chdir(@root_dir)
|
30
|
+
current_branch = git.current_branch
|
31
|
+
stash = git.branch.stashes.save("roku-builder-temp-stash")
|
32
|
+
git.checkout(branch)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Update manifest
|
36
|
+
build_version = ""
|
37
|
+
if update_manifest
|
38
|
+
build_version = ManifestManager.update_build(root_dir: root_dir, logger: @logger)
|
39
|
+
else
|
40
|
+
build_version = ManifestManager.build_version(root_dir: root_dir, logger: @logger)
|
41
|
+
end
|
42
|
+
|
43
|
+
outfile = build(root_dir: root_dir, branch: branch, build_version: build_version, folders: folders, files: files)
|
44
|
+
|
45
|
+
path = "/plugin_install"
|
46
|
+
|
47
|
+
# Connect to roku and upload file
|
48
|
+
conn = Faraday.new(url: @url) do |f|
|
49
|
+
f.request :digest, @dev_username, @dev_password
|
50
|
+
f.request :multipart
|
51
|
+
f.request :url_encoded
|
52
|
+
f.adapter Faraday.default_adapter
|
53
|
+
end
|
54
|
+
payload = {
|
55
|
+
mysubmit: "Replace",
|
56
|
+
archive: Faraday::UploadIO.new(outfile, 'application/zip')
|
57
|
+
}
|
58
|
+
response = conn.post path, payload
|
59
|
+
|
60
|
+
# Cleanup
|
61
|
+
File.delete(outfile)
|
62
|
+
|
63
|
+
if git and current_branch
|
64
|
+
git.checkout(current_branch)
|
65
|
+
git.branch.stashes.apply if stash
|
66
|
+
end
|
67
|
+
|
68
|
+
if response.status == 200 and response.body =~ /Install Success/
|
69
|
+
result = build_version
|
70
|
+
end
|
71
|
+
|
72
|
+
rescue Git::GitExecuteError => e
|
73
|
+
@logger.error "Branch or ref does not exist"
|
74
|
+
@logger.error e.message
|
75
|
+
@logger.error e.backtrace
|
76
|
+
ensure
|
77
|
+
Dir.chdir(current_dir) unless current_dir == Dir.pwd
|
78
|
+
end
|
79
|
+
result
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
# Build an app to sideload later
|
84
|
+
# @param root_dir [String] Path to the root directory of the roku app
|
85
|
+
# @param branch [String] Branch of the git repository to sideload. Pass nil to use working directory. Default: nil
|
86
|
+
# @param build_version [String] Version to assigne to the build. If nil will pull the build version form the manifest. Default: nil
|
87
|
+
# @param outfile [String] Path for the output file. If nil will create a file in /tmp. Default: nil
|
88
|
+
# @param fetch [Boolean] Flag to fetch all remotes before sideloading. Default: false
|
89
|
+
# @param folders [Array<String>] Array of folders to be sideloaded. Pass nil to send all folders. Default: nil
|
90
|
+
# @param files [Array<String>] Array of files to be sideloaded. Pass nil to send all files. Default: nil
|
91
|
+
# @return [String] Path of the build
|
92
|
+
def build(root_dir:, branch: nil, build_version: nil, outfile: nil, fetch: false, folders: nil, files: nil)
|
93
|
+
@root_dir = root_dir
|
94
|
+
result = nil
|
95
|
+
stash = nil
|
96
|
+
if branch
|
97
|
+
git = Git.open(@root_dir)
|
98
|
+
if fetch
|
99
|
+
for remote in git.remotes
|
100
|
+
git.fetch(remote)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
current_dir = Dir.pwd
|
105
|
+
begin
|
106
|
+
if git and branch and branch != git.current_branch
|
107
|
+
Dir.chdir(@root_dir)
|
108
|
+
current_branch = git.current_branch
|
109
|
+
stash = git.branch.stashes.save("roku-builder-temp-stash")
|
110
|
+
git.checkout(branch)
|
111
|
+
end
|
112
|
+
|
113
|
+
build_version = ManifestManager.build_version(root_dir: root_dir, logger: @logger) unless build_version
|
114
|
+
unless folders
|
115
|
+
folders = Dir.entries(root_dir).select {|entry| File.directory? File.join(root_dir, entry) and !(entry =='.' || entry == '..') }
|
116
|
+
end
|
117
|
+
unless files
|
118
|
+
files = Dir.entries(root_dir).select {|entry| File.file? File.join(root_dir, entry)}
|
119
|
+
end
|
120
|
+
outfile = "/tmp/build_#{build_version}.zip" unless outfile
|
121
|
+
|
122
|
+
File.delete(outfile) if File.exists?(outfile)
|
123
|
+
io = Zip::File.open(outfile, Zip::File::CREATE)
|
124
|
+
|
125
|
+
# Add folders to zip
|
126
|
+
folders.each do |folder|
|
127
|
+
base_folder = File.join(@root_dir, folder)
|
128
|
+
entries = Dir.entries(base_folder)
|
129
|
+
entries.delete(".")
|
130
|
+
entries.delete("..")
|
131
|
+
writeEntries(@root_dir, entries, folder, io)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Add file to zip
|
135
|
+
writeEntries(@root_dir, files, "", io)
|
136
|
+
|
137
|
+
io.close()
|
138
|
+
|
139
|
+
if git and current_branch
|
140
|
+
git.checkout(current_branch)
|
141
|
+
git.branch.stashes.apply if stash
|
142
|
+
end
|
143
|
+
rescue Git::GitExecuteError => e
|
144
|
+
@logger.error "Branch or ref does not exist"
|
145
|
+
@logger.error e.message
|
146
|
+
@logger.error e.backtrace
|
147
|
+
ensure
|
148
|
+
Dir.chdir(current_dir) unless current_dir == Dir.pwd
|
149
|
+
end
|
150
|
+
outfile
|
151
|
+
end
|
152
|
+
|
153
|
+
# Remove the currently sideloaded app
|
154
|
+
def unload()
|
155
|
+
path = "/plugin_install"
|
156
|
+
|
157
|
+
# Connect to roku and upload file
|
158
|
+
conn = Faraday.new(url: @url) do |f|
|
159
|
+
f.headers['Content-Type'] = Faraday::Request::Multipart.mime_type
|
160
|
+
f.request :digest, @dev_username, @dev_password
|
161
|
+
f.request :multipart
|
162
|
+
f.request :url_encoded
|
163
|
+
f.adapter Faraday.default_adapter
|
164
|
+
end
|
165
|
+
payload = {
|
166
|
+
mysubmit: "Delete",
|
167
|
+
archive: ""
|
168
|
+
}
|
169
|
+
response = conn.post path, payload
|
170
|
+
if response.status == 200 and response.body =~ /Install Success/
|
171
|
+
return true
|
172
|
+
end
|
173
|
+
return false
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
# Recursively write directory contents to a zip archive
|
179
|
+
# @param root_dir [String] Path of the root directory
|
180
|
+
# @param entries [Array<String>] Array of file paths of files/directories to store in the zip archive
|
181
|
+
# @param path [String] The path of the current directory starting at the root directory
|
182
|
+
# @param io [IO] zip IO object
|
183
|
+
def writeEntries(root_dir, entries, path, io)
|
184
|
+
entries.each { |e|
|
185
|
+
zipFilePath = path == "" ? e : File.join(path, e)
|
186
|
+
diskFilePath = File.join(root_dir, zipFilePath)
|
187
|
+
if File.directory?(diskFilePath)
|
188
|
+
io.mkdir(zipFilePath)
|
189
|
+
subdir =Dir.entries(diskFilePath); subdir.delete("."); subdir.delete("..")
|
190
|
+
writeEntries(root_dir, subdir, zipFilePath, io)
|
191
|
+
else
|
192
|
+
io.get_output_stream(zipFilePath) { |f| f.puts(File.open(diskFilePath, "rb").read()) }
|
193
|
+
end
|
194
|
+
}
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module RokuBuilder
|
2
|
+
|
3
|
+
# Updates or retrives build version
|
4
|
+
class ManifestManager
|
5
|
+
|
6
|
+
# Updates the build version in the manifest file
|
7
|
+
# @param root_dir [String] Path to the root directory for the app
|
8
|
+
# @return [String] Build version on success, empty string otherwise
|
9
|
+
def self.update_build(root_dir:, logger:)
|
10
|
+
|
11
|
+
build_version = ""
|
12
|
+
|
13
|
+
temp_file = Tempfile.new('manifest')
|
14
|
+
path = File.join(root_dir, 'manifest')
|
15
|
+
begin
|
16
|
+
File.open(path, 'r') do |file|
|
17
|
+
file.each_line do |line|
|
18
|
+
if line.include?("build_version")
|
19
|
+
|
20
|
+
#Update build version.
|
21
|
+
build_version = line.split(".")
|
22
|
+
iteration = 0
|
23
|
+
if 2 == build_version.length
|
24
|
+
iteration = build_version[1].to_i + 1
|
25
|
+
build_version[0] = Time.now.strftime("%m%d%y")
|
26
|
+
build_version[1] = iteration
|
27
|
+
build_version = build_version.join(".")
|
28
|
+
else
|
29
|
+
#Use current date.
|
30
|
+
build_version = Time.now.strftime("%m%d%y")+".1"
|
31
|
+
end
|
32
|
+
temp_file.puts "build_version=#{build_version}"
|
33
|
+
else
|
34
|
+
temp_file.puts line
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
temp_file.rewind
|
39
|
+
FileUtils.cp(temp_file.path, path)
|
40
|
+
ensure
|
41
|
+
temp_file.close
|
42
|
+
temp_file.unlink
|
43
|
+
end
|
44
|
+
build_version
|
45
|
+
end
|
46
|
+
|
47
|
+
# Retrive the build version from the manifest file
|
48
|
+
# @param root_dir [String] Path to the root directory for the app
|
49
|
+
# @return [String] Build version on success, empty string otherwise
|
50
|
+
def self.build_version(root_dir:, logger:)
|
51
|
+
path = File.join(root_dir, 'manifest')
|
52
|
+
build_version = ""
|
53
|
+
File.open(path, 'r') do |file|
|
54
|
+
file.each_line do |line|
|
55
|
+
if line.include?("build_version")
|
56
|
+
build_version = line.split("=")[1].chomp
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
build_version
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|