mysigner 0.3.4 → 0.3.7
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 +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/CHANGELOG.md +47 -0
- data/Gemfile +0 -1
- data/Gemfile.lock +2 -6
- data/README.md +16 -16
- data/lib/mysigner/build/android_executor.rb +16 -21
- data/lib/mysigner/build/detector.rb +3 -1
- data/lib/mysigner/build/executor.rb +6 -1
- data/lib/mysigner/cli/auth_commands.rb +14 -3
- data/lib/mysigner/cli/build_commands.rb +14 -11
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +1 -2
- data/lib/mysigner/cli/concerns/api_helpers.rb +16 -3
- data/lib/mysigner/cli/concerns/error_handlers.rb +0 -1
- data/lib/mysigner/cli/concerns/helpers.rb +14 -14
- data/lib/mysigner/cli/diagnostic_commands.rb +16 -5
- data/lib/mysigner/cli/resource_commands.rb +8 -1
- data/lib/mysigner/client.rb +52 -0
- data/lib/mysigner/config.rb +9 -4
- data/lib/mysigner/export/exporter.rb +6 -1
- data/lib/mysigner/formatting.rb +23 -0
- data/lib/mysigner/signing/certificate_checker.rb +6 -6
- data/lib/mysigner/signing/keystore_manager.rb +2 -0
- data/lib/mysigner/signing/wizard.rb +2 -0
- data/lib/mysigner/upload/app_store_automation.rb +13 -1
- data/lib/mysigner/upload/app_store_submission.rb +2 -14
- data/lib/mysigner/upload/asc_rest_uploader.rb +44 -3
- data/lib/mysigner/upload/asc_submitter.rb +5 -0
- data/lib/mysigner/upload/play_store_uploader.rb +2 -7
- data/lib/mysigner/upload/uploader.rb +9 -366
- data/lib/mysigner/version.rb +1 -1
- data/lib/mysigner.rb +1 -0
- data/mysigner.gemspec +6 -2
- metadata +2 -20
- data/.travis.yml +0 -7
- data/MANUAL_TEST.md +0 -341
- data/bin/console +0 -15
- data/bin/setup +0 -11
- data/test_manual.rb +0 -103
data/lib/mysigner/client.rb
CHANGED
|
@@ -3,11 +3,45 @@
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'faraday/retry'
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'uri'
|
|
6
7
|
|
|
7
8
|
module Mysigner
|
|
8
9
|
class Client
|
|
9
10
|
attr_reader :api_url, :api_token, :user_email
|
|
10
11
|
|
|
12
|
+
# Hosts for which plain http is acceptable (local development). The org
|
|
13
|
+
# API token must NEVER be sent in cleartext to anything else.
|
|
14
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
|
|
15
|
+
|
|
16
|
+
# Refuse to attach the API token to an insecure endpoint. api_url comes
|
|
17
|
+
# from ~/.mysigner/config.yml or MYSIGNER_API_URL, so without this a
|
|
18
|
+
# poisoned config/env (or a bare-host URL the normalizer once downgraded
|
|
19
|
+
# to http://) would ship the Bearer token to an attacker-chosen host in
|
|
20
|
+
# cleartext. https is always allowed; http only for a loopback dev host.
|
|
21
|
+
def self.assert_secure_api_url!(url)
|
|
22
|
+
# A blank/nil URL means "not configured" — let the existing not-logged-in
|
|
23
|
+
# paths handle that rather than reporting a confusing "insecure" error.
|
|
24
|
+
return if url.to_s.strip.empty?
|
|
25
|
+
|
|
26
|
+
uri = begin
|
|
27
|
+
URI.parse(url.to_s)
|
|
28
|
+
rescue URI::InvalidURIError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return if uri && uri.scheme == 'https'
|
|
33
|
+
|
|
34
|
+
# uri.hostname (unlike uri.host) already strips IPv6 brackets, so
|
|
35
|
+
# http://[::1]:3000 resolves to the loopback host "::1".
|
|
36
|
+
host = uri&.hostname.to_s.downcase
|
|
37
|
+
return if uri && uri.scheme == 'http' && LOOPBACK_HOSTS.include?(host)
|
|
38
|
+
|
|
39
|
+
raise InsecureUrlError,
|
|
40
|
+
"Refusing to send your API token over an insecure connection (#{url}). " \
|
|
41
|
+
'Use an https:// URL — plain http is allowed only for localhost. ' \
|
|
42
|
+
'Fix your api_url with `mysigner login` or the MYSIGNER_API_URL env var.'
|
|
43
|
+
end
|
|
44
|
+
|
|
11
45
|
def initialize(api_url:, api_token:, user_email: nil)
|
|
12
46
|
@api_url = api_url
|
|
13
47
|
@api_token = api_token
|
|
@@ -65,7 +99,22 @@ module Mysigner
|
|
|
65
99
|
|
|
66
100
|
# Expose connection for direct access (e.g., binary downloads)
|
|
67
101
|
def connection
|
|
102
|
+
Client.assert_secure_api_url!(@api_url)
|
|
68
103
|
@connection ||= Faraday.new(url: @api_url) do |f|
|
|
104
|
+
# Fail fast on a stalled connection instead of hanging the CLI
|
|
105
|
+
# forever. Without these the default adapter applies no timeout, so a
|
|
106
|
+
# server that accepts the socket but never responds blocks every
|
|
107
|
+
# command indefinitely and the Faraday::TimeoutError branch below is
|
|
108
|
+
# effectively unreachable. The read timeout is generous because the
|
|
109
|
+
# same client streams binary downloads.
|
|
110
|
+
f.options.timeout = 120
|
|
111
|
+
f.options.open_timeout = 10
|
|
112
|
+
|
|
113
|
+
# Assert TLS verification as an in-code invariant rather than relying
|
|
114
|
+
# on the adapter default — an adapter swap or stray env OpenSSL config
|
|
115
|
+
# could otherwise silently weaken it. (No effect on http://localhost.)
|
|
116
|
+
f.ssl.verify = true
|
|
117
|
+
|
|
69
118
|
# Request middleware
|
|
70
119
|
f.request :authorization, 'Bearer', @api_token
|
|
71
120
|
f.request :json
|
|
@@ -192,6 +241,9 @@ module Mysigner
|
|
|
192
241
|
class ForbiddenError < ClientError; end
|
|
193
242
|
class NotFoundError < ClientError; end
|
|
194
243
|
class ServerError < ClientError; end
|
|
244
|
+
# Raised when the API token would be sent over an insecure (non-https,
|
|
245
|
+
# non-loopback) connection. See Client.assert_secure_api_url!.
|
|
246
|
+
class InsecureUrlError < ClientError; end
|
|
195
247
|
|
|
196
248
|
class ValidationError < ClientError
|
|
197
249
|
def initialize(message, details = nil, suggestion: nil, error_code: nil, timestamp: nil)
|
data/lib/mysigner/config.rb
CHANGED
|
@@ -212,10 +212,15 @@ module Mysigner
|
|
|
212
212
|
|
|
213
213
|
File.delete(CONFIG_FILE) if exists?
|
|
214
214
|
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
|
|
215
|
+
# Deliberately KEEP the per-machine encryption key (KEY_FILE). On
|
|
216
|
+
# non-macOS it also wraps any local-only credentials in the file
|
|
217
|
+
# fallback (~/.mysigner/credentials), which a plain logout KEEPS — only
|
|
218
|
+
# `mysigner logout --purge` deletes them. Wiping the key here would
|
|
219
|
+
# leave those kept credentials permanently undecryptable. The key on its
|
|
220
|
+
# own is a non-secret per-machine value, and the encrypted token it
|
|
221
|
+
# protected is already gone with CONFIG_FILE above; re-login simply
|
|
222
|
+
# re-uses it. (--purge removes the credentials themselves, so no orphan
|
|
223
|
+
# remains in that flow either.)
|
|
219
224
|
|
|
220
225
|
# Phase 0: logout also purges the keystore cache so a shared machine
|
|
221
226
|
# doesn't leave prior-user keystore blobs on disk.
|
|
@@ -99,6 +99,11 @@ module Mysigner
|
|
|
99
99
|
FileUtils.mkdir_p(@output_dir)
|
|
100
100
|
puts ''
|
|
101
101
|
|
|
102
|
+
# Keep this an argv ARRAY (no .join(' ')). IO.popen(array) execs
|
|
103
|
+
# xcodebuild directly with no shell, so an archive/output path
|
|
104
|
+
# containing a space (e.g. ~/My Projects/App.xcarchive) or a shell
|
|
105
|
+
# metacharacter survives as one literal argument instead of being
|
|
106
|
+
# split by /bin/sh or interpreted.
|
|
102
107
|
cmd = [
|
|
103
108
|
'xcodebuild',
|
|
104
109
|
'-exportArchive',
|
|
@@ -106,7 +111,7 @@ module Mysigner
|
|
|
106
111
|
'-exportPath', @output_dir,
|
|
107
112
|
'-exportOptionsPlist', options_plist,
|
|
108
113
|
'-allowProvisioningUpdates' # Allow Xcode to update profiles if needed
|
|
109
|
-
]
|
|
114
|
+
]
|
|
110
115
|
|
|
111
116
|
# Run command and capture output. xcodebuild output can contain
|
|
112
117
|
# non-ASCII bytes (smart quotes in Apple error messages, emoji in
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mysigner
|
|
4
|
+
# Shared human-readable formatting helpers — single source of truth so the
|
|
5
|
+
# CLI concern and the upload classes don't each carry their own copy.
|
|
6
|
+
#
|
|
7
|
+
# NOTE: format_duration is deliberately NOT here. The CLI concern's
|
|
8
|
+
# format_duration ("2m 5s" units) and AppStoreAutomation's ("02:05" clock)
|
|
9
|
+
# are different formats for different displays, not duplicates.
|
|
10
|
+
module Formatting
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def format_bytes(bytes)
|
|
14
|
+
if bytes < 1024
|
|
15
|
+
"#{bytes} B"
|
|
16
|
+
elsif bytes < 1024 * 1024
|
|
17
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
18
|
+
else
|
|
19
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -74,9 +74,10 @@ module Mysigner
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def get_certificate_details(name)
|
|
77
|
-
# Get certificate in PEM format by name
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
# Get certificate in PEM format by name. argv form (no shell) so a
|
|
78
|
+
# certificate CN containing $(...) / backticks — which a crafted
|
|
79
|
+
# imported keychain item can carry — is passed literally, not executed.
|
|
80
|
+
stdout, _, status = Open3.capture3('security', 'find-certificate', '-c', name, '-p')
|
|
80
81
|
|
|
81
82
|
# If not found, return nil
|
|
82
83
|
return nil if !status.success? || stdout.empty?
|
|
@@ -88,9 +89,8 @@ module Mysigner
|
|
|
88
89
|
temp.write(stdout)
|
|
89
90
|
temp.close
|
|
90
91
|
|
|
91
|
-
# Get expiry date using openssl
|
|
92
|
-
|
|
93
|
-
out, _, stat = Open3.capture3(cmd)
|
|
92
|
+
# Get expiry date using openssl (argv form, no shell)
|
|
93
|
+
out, _, stat = Open3.capture3('openssl', 'x509', '-in', temp.path, '-noout', '-enddate')
|
|
94
94
|
|
|
95
95
|
if stat.success? && out =~ /notAfter=(.+)/
|
|
96
96
|
expiry_str = ::Regexp.last_match(1).strip
|
|
@@ -244,6 +244,8 @@ module Mysigner
|
|
|
244
244
|
config = Mysigner::Config.new
|
|
245
245
|
config.load if config.exists?
|
|
246
246
|
|
|
247
|
+
# Never attach the API token to a non-https (non-loopback) endpoint.
|
|
248
|
+
Mysigner::Client.assert_secure_api_url!(config.api_url)
|
|
247
249
|
Faraday.new(url: config.api_url) do |f|
|
|
248
250
|
f.request :authorization, 'Bearer', config.api_token
|
|
249
251
|
f.options.timeout = 60
|
|
@@ -414,6 +414,8 @@ module Mysigner
|
|
|
414
414
|
# (the client's get method uses JSON middleware which corrupts binary data)
|
|
415
415
|
download_url = "/api/v1/organizations/#{@organization_id}/profiles/#{profile['id']}/download"
|
|
416
416
|
|
|
417
|
+
# Never attach the API token to a non-https (non-loopback) endpoint.
|
|
418
|
+
Mysigner::Client.assert_secure_api_url!(@client.api_url)
|
|
417
419
|
conn = Faraday.new(url: @client.api_url) do |f|
|
|
418
420
|
f.request :authorization, 'Bearer', @client.api_token
|
|
419
421
|
f.adapter Faraday.default_adapter
|
|
@@ -150,7 +150,7 @@ module Mysigner
|
|
|
150
150
|
|
|
151
151
|
if elapsed >= @timeout
|
|
152
152
|
status[:timed_out] = true
|
|
153
|
-
puts "\r✗ Timed out after #{format_duration(elapsed)}
|
|
153
|
+
puts "\r✗ Timed out after #{format_duration(elapsed)} — the build is still processing on Apple's side".ljust(90)
|
|
154
154
|
puts ''
|
|
155
155
|
return [build, status]
|
|
156
156
|
end
|
|
@@ -160,6 +160,18 @@ module Mysigner
|
|
|
160
160
|
$stdout.flush
|
|
161
161
|
sleep @poll_interval
|
|
162
162
|
end
|
|
163
|
+
rescue Interrupt
|
|
164
|
+
# Only the polling loop draws the "Waiting …" line, so only it needs the
|
|
165
|
+
# resume hint; if we weren't actually waiting, let Ctrl-C propagate.
|
|
166
|
+
raise unless @wait_enabled
|
|
167
|
+
|
|
168
|
+
# Ctrl-C during the wait: don't leave a half-drawn "Waiting …" line with
|
|
169
|
+
# no newline. The build keeps processing on Apple's side — tell the user
|
|
170
|
+
# how to resume, then exit with the conventional SIGINT code.
|
|
171
|
+
puts ''
|
|
172
|
+
puts '⏹ Stopped waiting — your build is still processing on Apple’s side.'
|
|
173
|
+
puts ' Re-run `mysigner ship appstore --wait` to resume watching.'
|
|
174
|
+
exit 130
|
|
163
175
|
end
|
|
164
176
|
|
|
165
177
|
def latest_build(app_id, build_info)
|
|
@@ -91,18 +91,6 @@ module Mysigner
|
|
|
91
91
|
nil
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# Fetch release config and extract min_build_number for smart build selection
|
|
95
|
-
def fetch_release_config
|
|
96
|
-
metadata = fetch_release_metadata
|
|
97
|
-
return {} unless metadata
|
|
98
|
-
|
|
99
|
-
config = {}
|
|
100
|
-
config[:min_build_number] = metadata['build_number'].to_i if metadata['build_number']
|
|
101
|
-
config[:release_type] = metadata['release_type'] if metadata['release_type']
|
|
102
|
-
config[:earliest_release_date] = metadata['earliest_release_date'] if metadata['earliest_release_date']
|
|
103
|
-
config
|
|
104
|
-
end
|
|
105
|
-
|
|
106
94
|
def merge_metadata(api_metadata)
|
|
107
95
|
api_data = stringify_keys(api_metadata || {})
|
|
108
96
|
overrides = stringify_keys(@metadata_overrides)
|
|
@@ -133,8 +121,8 @@ module Mysigner
|
|
|
133
121
|
puts '💡 Auto-submit enabled' if metadata && metadata['auto_submit']
|
|
134
122
|
|
|
135
123
|
puts ''
|
|
136
|
-
puts 'Tip:
|
|
137
|
-
puts '
|
|
124
|
+
puts 'Tip: `mysigner ship appstore` uploads AND submits for review automatically.'
|
|
125
|
+
puts ' Pass --no-auto-submit to upload only, or --wait to watch processing.'
|
|
138
126
|
puts ''
|
|
139
127
|
end
|
|
140
128
|
|
|
@@ -37,6 +37,7 @@ module Mysigner
|
|
|
37
37
|
POLL_INTERVAL = 10
|
|
38
38
|
POLL_TIMEOUT = 600
|
|
39
39
|
CHUNK_RETRIES = 2
|
|
40
|
+
DIGEST_READ_BYTES = 1024 * 1024 # 1 MiB streaming buffer for the digest pass
|
|
40
41
|
|
|
41
42
|
APPLE_ASC_BASE = 'https://api.appstoreconnect.apple.com'
|
|
42
43
|
|
|
@@ -66,6 +67,9 @@ module Mysigner
|
|
|
66
67
|
# args (which preserves the Keychain-only behavior the existing specs
|
|
67
68
|
# pin).
|
|
68
69
|
@asc_creds = asc_creds
|
|
70
|
+
# Path of a .p8 WE materialized this run (set by ensure_p8_in_apple_dir!),
|
|
71
|
+
# deleted by cleanup_materialized_p8! after altool. nil = nothing to clean.
|
|
72
|
+
@materialized_p8_path = nil
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def call
|
|
@@ -77,8 +81,7 @@ module Mysigner
|
|
|
77
81
|
ops.each { |op| put_chunk_with_retry(f, op) }
|
|
78
82
|
end
|
|
79
83
|
|
|
80
|
-
md5 =
|
|
81
|
-
sha = Digest::SHA256.file(@ipa_path).hexdigest
|
|
84
|
+
md5, sha = compute_file_digests(@ipa_path)
|
|
82
85
|
|
|
83
86
|
mark_uploaded_via_server(build_upload_id, md5: md5, sha: sha)
|
|
84
87
|
|
|
@@ -88,6 +91,21 @@ module Mysigner
|
|
|
88
91
|
|
|
89
92
|
private
|
|
90
93
|
|
|
94
|
+
# Compute MD5 + SHA-256 in a SINGLE streamed pass over the IPA. The old
|
|
95
|
+
# code called Digest::MD5.file then Digest::SHA256.file, reading the whole
|
|
96
|
+
# (often hundreds-of-MB) file end-to-end twice; this reads it once.
|
|
97
|
+
def compute_file_digests(path)
|
|
98
|
+
md5 = Digest::MD5.new
|
|
99
|
+
sha = Digest::SHA256.new
|
|
100
|
+
File.open(path, 'rb') do |f|
|
|
101
|
+
while (chunk = f.read(DIGEST_READ_BYTES))
|
|
102
|
+
md5.update(chunk)
|
|
103
|
+
sha.update(chunk)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
[md5.hexdigest, sha.hexdigest]
|
|
107
|
+
end
|
|
108
|
+
|
|
91
109
|
# Local-only: shell out to `xcrun altool --upload-app`. altool is
|
|
92
110
|
# Apple's canonical CLI for App Store uploads — it handles the multi-
|
|
93
111
|
# step buildUploads/buildUploadFiles/chunk-PUT/commit dance correctly
|
|
@@ -110,6 +128,12 @@ module Mysigner
|
|
|
110
128
|
return { build_upload_id: nil, final_state: 'COMPLETE' } if status.success?
|
|
111
129
|
|
|
112
130
|
raise_altool_failure!(stdout_stderr)
|
|
131
|
+
ensure
|
|
132
|
+
# Never leave the plaintext ASC private key sitting at Apple's
|
|
133
|
+
# well-known discovery path after the upload — delete the one WE
|
|
134
|
+
# materialized this run (cleanup_materialized_p8! is a no-op when the
|
|
135
|
+
# user already had their own key there).
|
|
136
|
+
cleanup_materialized_p8!
|
|
113
137
|
end
|
|
114
138
|
|
|
115
139
|
# `xcrun altool --upload-app --file ... --type ios --apiKey KEY_ID
|
|
@@ -148,17 +172,34 @@ module Mysigner
|
|
|
148
172
|
def ensure_p8_in_apple_dir!(creds)
|
|
149
173
|
FileUtils.mkdir_p(APPLE_PRIVATE_KEYS_DIR, mode: 0o700)
|
|
150
174
|
target = File.join(APPLE_PRIVATE_KEYS_DIR, "AuthKey_#{creds.key_id}.p8")
|
|
175
|
+
pre_existing = File.exist?(target)
|
|
151
176
|
|
|
152
|
-
if
|
|
177
|
+
if pre_existing && File.read(target) == creds.p8_pem
|
|
153
178
|
File.chmod(0o600, target)
|
|
179
|
+
# The user already had this exact key here — leave it untouched.
|
|
180
|
+
@materialized_p8_path = nil
|
|
154
181
|
return target
|
|
155
182
|
end
|
|
156
183
|
|
|
184
|
+
# Mark for cleanup BEFORE writing, so a failure between the write and
|
|
185
|
+
# the chmod still lets the ensure block remove the freshly-written
|
|
186
|
+
# plaintext key. Only when we created it fresh — if a (different) file
|
|
187
|
+
# pre-existed we must not delete the key the user manages themselves.
|
|
188
|
+
@materialized_p8_path = pre_existing ? nil : target
|
|
157
189
|
File.write(target, creds.p8_pem)
|
|
158
190
|
File.chmod(0o600, target)
|
|
159
191
|
target
|
|
160
192
|
end
|
|
161
193
|
|
|
194
|
+
# Remove the .p8 we materialized for altool this run, if any. Idempotent
|
|
195
|
+
# and best-effort (a failure to delete must not mask the upload result).
|
|
196
|
+
def cleanup_materialized_p8!
|
|
197
|
+
return unless @materialized_p8_path
|
|
198
|
+
|
|
199
|
+
FileUtils.rm_f(@materialized_p8_path)
|
|
200
|
+
@materialized_p8_path = nil
|
|
201
|
+
end
|
|
202
|
+
|
|
162
203
|
# Parse altool's --output-format json blob. The error path is:
|
|
163
204
|
# { "product-errors": [ { "code": ..., "message": "..." }, ... ] }
|
|
164
205
|
# Any "build version already exists" diagnostic — Apple's ITMS-90... —
|
|
@@ -379,6 +379,11 @@ module Mysigner
|
|
|
379
379
|
|
|
380
380
|
def http_conn
|
|
381
381
|
@http_conn ||= Faraday.new(url: APPLE_ASC_BASE) do |f|
|
|
382
|
+
# Explicit timeouts so a stalled Apple connection can't hang the
|
|
383
|
+
# 30-minute poll loop forever (the per-request rescue only fires on
|
|
384
|
+
# an actual error, not a silent stall).
|
|
385
|
+
f.options.timeout = 60
|
|
386
|
+
f.options.open_timeout = 10
|
|
382
387
|
f.adapter Faraday.default_adapter
|
|
383
388
|
end
|
|
384
389
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'mysigner/formatting'
|
|
4
5
|
|
|
5
6
|
module Mysigner
|
|
6
7
|
module Upload
|
|
@@ -477,13 +478,7 @@ module Mysigner
|
|
|
477
478
|
end
|
|
478
479
|
|
|
479
480
|
def format_bytes(bytes)
|
|
480
|
-
|
|
481
|
-
"#{bytes} B"
|
|
482
|
-
elsif bytes < 1024 * 1024
|
|
483
|
-
"#{(bytes / 1024.0).round(1)} KB"
|
|
484
|
-
else
|
|
485
|
-
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
486
|
-
end
|
|
481
|
+
Mysigner::Formatting.format_bytes(bytes)
|
|
487
482
|
end
|
|
488
483
|
end
|
|
489
484
|
end
|