appship 0.1.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ class Config
5
+ TEMPLATE = <<~YAML
6
+ # appship configuration. Secrets should be supplied through environment variables.
7
+ # Run `appship doctor` to validate the local toolchain.
8
+ workspace: MyApp.xcworkspace
9
+ # project: MyApp.xcodeproj
10
+ scheme: MyApp
11
+ configuration: Debug
12
+ sdk: iphoneos
13
+ output: build/MyApp-Debug.ipa
14
+
15
+ # Optional icon badge. Remove or set to null to disable it.
16
+ # badge: DEBUG
17
+ # assets: MyApp/Assets.xcassets
18
+ # app_icon: AppIcon
19
+
20
+ upload:
21
+ provider: pgyer
22
+ api_key_env: PGYER_API_KEY
23
+ # fir_api_token_env: FIR_API_TOKEN
24
+ # fir_password_env: FIR_PASSWORD
25
+ install_type: 1
26
+ # password_env: PGYER_INSTALL_PASSWORD
27
+ # channel: internal
28
+ YAML
29
+
30
+ attr_reader :path, :data
31
+
32
+ def initialize(path = nil)
33
+ @path = path && File.expand_path(path)
34
+ @data = if @path && File.file?(@path)
35
+ YAML.safe_load(File.read(@path), permitted_classes: [], aliases: false) || {}
36
+ else
37
+ {}
38
+ end
39
+ raise ConfigurationError, "配置文件必须是 YAML 对象: #{@path}" unless @data.is_a?(Hash)
40
+ rescue Psych::SyntaxError => e
41
+ raise ConfigurationError, "配置文件解析失败: #{e.message}"
42
+ end
43
+
44
+ def get(*keys, default: nil)
45
+ value = keys.reduce(@data) do |current, key|
46
+ current.is_a?(Hash) ? current[key.to_s] : nil
47
+ end
48
+ value.nil? ? default : value
49
+ end
50
+
51
+ def self.find(path = nil)
52
+ return new(path) if path
53
+
54
+ candidates = [
55
+ File.join(Dir.pwd, ".appship.yml"),
56
+ File.join(Dir.pwd, ".appship.yaml"),
57
+ # Backward compatibility with the first provider-specific prototype.
58
+ File.join(Dir.pwd, ".pgyer.yml"),
59
+ File.join(Dir.pwd, ".pgyer.yaml")
60
+ ]
61
+ new(candidates.find { |candidate| File.file?(candidate) })
62
+ end
63
+
64
+ def self.write_template(path, force: false)
65
+ expanded = File.expand_path(path)
66
+ if File.exist?(expanded) && !force
67
+ raise ConfigurationError, "文件已存在: #{expanded}(使用 --force 覆盖)"
68
+ end
69
+
70
+ FileUtils.mkdir_p(File.dirname(expanded))
71
+ File.write(expanded, TEMPLATE)
72
+ expanded
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class CommandError < Error; end
7
+ class UploadError < Error; end
8
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ module Ipa
5
+ module_function
6
+
7
+ def package!(app, output:, badge: nil, assets: nil, app_icon: nil, runner: Runner.new)
8
+ runner.require_command!("zip", "macOS 通常自带 zip")
9
+ root = Dir.mktmpdir("appship-ipa-")
10
+ begin
11
+ payload = File.join(root, "Payload")
12
+ FileUtils.mkdir_p(payload)
13
+ FileUtils.cp_r(app, File.join(payload, File.basename(app)))
14
+ if badge
15
+ Badger.apply!(File.join(payload, File.basename(app)), assets: assets, app_icon: app_icon, text: badge, runner: runner)
16
+ end
17
+ zip_directory!(root, output, runner)
18
+ ensure
19
+ FileUtils.rm_rf(root)
20
+ end
21
+ output
22
+ end
23
+
24
+ def apply_badge!(ipa_path, assets:, app_icon:, text:, runner: Runner.new)
25
+ runner.require_command!("unzip", "macOS 通常自带 unzip")
26
+ runner.require_command!("zip", "macOS 通常自带 zip")
27
+ root = Dir.mktmpdir("appship-badge-")
28
+ begin
29
+ runner.run!(["unzip", "-q", ipa_path, "-d", root])
30
+ app = Dir[File.join(root, "Payload", "*.app")].first
31
+ raise ConfigurationError, "IPA 内没有找到 Payload/*.app" unless app
32
+
33
+ Badger.apply!(app, assets: assets, app_icon: app_icon, text: text, runner: runner)
34
+ replacement = File.join(root, "rebuilt.ipa")
35
+ zip_directory!(root, replacement, runner, exclude: [File.basename(replacement)])
36
+ FileUtils.mv(replacement, ipa_path, force: true)
37
+ ensure
38
+ FileUtils.rm_rf(root)
39
+ end
40
+ ipa_path
41
+ end
42
+
43
+ def zip_directory!(root, output, runner, exclude: [])
44
+ FileUtils.mkdir_p(File.dirname(output))
45
+ entries = Dir.children(root).reject { |entry| exclude.include?(entry) }
46
+ raise CommandError, "没有可打包的 IPA 内容" if entries.empty?
47
+
48
+ runner.run!(["zip", "-qr", output, *entries], chdir: root)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ class Runner
5
+ attr_reader :verbose
6
+
7
+ def initialize(verbose: false)
8
+ @verbose = verbose
9
+ end
10
+
11
+ def self.which(command)
12
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
13
+ candidate = File.join(directory, command)
14
+ return candidate if File.file?(candidate) && File.executable?(candidate)
15
+ end
16
+ nil
17
+ end
18
+
19
+ def require_command!(command, explanation = nil)
20
+ return Runner.which(command) if Runner.which(command)
21
+
22
+ message = "找不到命令: #{command}"
23
+ message += "(#{explanation})" if explanation
24
+ raise ConfigurationError, message
25
+ end
26
+
27
+ def run!(argv, chdir: nil, env: {})
28
+ puts "$ #{Shellwords.join(argv)}" if verbose
29
+ status = nil
30
+ spawn_options = {}
31
+ spawn_options[:chdir] = chdir if chdir
32
+ Open3.popen2e(env, *argv, **spawn_options) do |_stdin, output, wait_thread|
33
+ output.each_line { |line| print line }
34
+ status = wait_thread.value
35
+ end
36
+ return true if status&.success?
37
+
38
+ raise CommandError, "命令执行失败(退出码 #{status&.exitstatus || "unknown"}): #{argv.first}"
39
+ rescue Errno::ENOENT
40
+ raise ConfigurationError, "找不到命令: #{argv.first}"
41
+ end
42
+
43
+ def capture!(argv, chdir: nil, env: {})
44
+ spawn_options = {}
45
+ spawn_options[:chdir] = chdir if chdir
46
+ stdout, stderr, status = Open3.capture3(env, *argv, **spawn_options)
47
+ return stdout if status.success?
48
+
49
+ detail = [stdout, stderr].reject(&:empty?).join("\n")
50
+ raise CommandError, "命令执行失败: #{Shellwords.join(argv)}\n#{detail}"
51
+ rescue Errno::ENOENT
52
+ raise ConfigurationError, "找不到命令: #{argv.first}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,490 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ class Uploader
5
+ SUPPORTED_PROVIDERS = %w[pgyer fir].freeze
6
+
7
+ def initialize(options = {})
8
+ provider = (options[:provider] || "pgyer").to_s.downcase
9
+ provider = "fir" if provider == "fir.im"
10
+ unless SUPPORTED_PROVIDERS.include?(provider)
11
+ raise ConfigurationError, "不支持的分发平台: #{options[:provider]}(可选 pgyer 或 fir)"
12
+ end
13
+
14
+ @delegate = provider == "fir" ? FirUploader.new(options) : PgyerUploader.new(options)
15
+ end
16
+
17
+ def upload!(file)
18
+ @delegate.upload!(file)
19
+ end
20
+ end
21
+
22
+ class PgyerUploader
23
+ PGYER_HOST = "www.pgyer.com"
24
+
25
+ def initialize(options = {})
26
+ @options = options
27
+ @api_key = options[:api_key] || ENV[options[:api_key_env] || "PGYER_API_KEY"]
28
+ raise ConfigurationError, "缺少蒲公英 API Key,请使用 --api-key 或 PGYER_API_KEY" if @api_key.to_s.empty?
29
+ end
30
+
31
+ def upload!(file)
32
+ raise ConfigurationError, "IPA/APK 文件不存在: #{file}" unless File.file?(file)
33
+
34
+ puts "☁️ 获取蒲公英上传凭证..."
35
+ token = request_token(file)
36
+ puts "☁️ 上传 #{File.basename(file)}(#{format_size(File.size(file))})..."
37
+ upload_to_cos!(file, token)
38
+ puts "☁️ 等待蒲公英处理..."
39
+ poll_build_info!(token.fetch(:build_key))
40
+ end
41
+
42
+ private
43
+
44
+ def request_token(file)
45
+ fields = {
46
+ "_api_key" => @api_key,
47
+ "buildType" => File.extname(file).delete_prefix(".").downcase,
48
+ "buildInstallType" => (@options[:install_type] || 1).to_s
49
+ }
50
+ add_field(fields, "buildPassword", @options[:password] || env_value(@options[:password_env]))
51
+ add_field(fields, "buildUpdateDescription", @options[:description])
52
+ add_field(fields, "buildInstallDate", @options[:install_date])
53
+ add_field(fields, "buildInstallStartDate", @options[:start_date])
54
+ add_field(fields, "buildInstallEndDate", @options[:end_date])
55
+ add_field(fields, "buildChannelShortcut", @options[:channel])
56
+
57
+ response = form_post!("https://#{PGYER_HOST}/apiv2/app/getCOSToken", fields)
58
+ data = response["data"].is_a?(Hash) ? response["data"] : response
59
+ endpoint = deep_find(data, "endpoint")
60
+ key = deep_find(data, "key")
61
+ signature = deep_find(data, "signature")
62
+ security_token = deep_find(data, "x-cos-security-token")
63
+ unless [endpoint, key, signature, security_token].all? { |value| value && !value.to_s.empty? }
64
+ raise UploadError, "获取蒲公英上传凭证失败: #{response}"
65
+ end
66
+
67
+ { endpoint: endpoint, key: key, signature: signature, security_token: security_token, build_key: key }
68
+ end
69
+
70
+ def upload_to_cos!(file, token)
71
+ fields = {
72
+ "key" => token[:key],
73
+ "signature" => token[:signature],
74
+ "x-cos-security-token" => token[:security_token],
75
+ "x-cos-meta-file-name" => File.basename(file)
76
+ }
77
+ uri = URI.parse(token[:endpoint])
78
+ boundary = "----AppshipUpload#{SecureRandom.hex(12)}"
79
+ progress = UploadProgress.new(File.size(file))
80
+ progress.start
81
+ body = MultipartBody.new(fields, file, boundary, progress: progress.method(:update))
82
+ request = Net::HTTP::Post.new(uri)
83
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
84
+ request["Content-Length"] = body.length.to_s
85
+ request.body_stream = body
86
+ response = http(uri).request(request)
87
+ return true if response.code.to_i == 204 || response.is_a?(Net::HTTPSuccess)
88
+
89
+ raise UploadError, "蒲公英文件上传失败(HTTP #{response.code}): #{response.body}"
90
+ ensure
91
+ progress&.finish
92
+ end
93
+
94
+ def poll_build_info!(build_key)
95
+ attempts = (@options[:poll_attempts] || 60).to_i
96
+ attempts = 1 if attempts < 1
97
+ attempts.times do |index|
98
+ query = URI.encode_www_form("_api_key" => @api_key, "buildKey" => build_key)
99
+ uri = URI.parse("https://#{PGYER_HOST}/apiv2/app/buildInfo?#{query}")
100
+ response = http(uri).request(Net::HTTP::Get.new(uri))
101
+ json = parse_json!(response)
102
+ return json if json["code"].to_i == 0
103
+
104
+ sleep 1 if index < attempts - 1
105
+ end
106
+ raise UploadError, "文件已上传,但 #{attempts} 秒内未查询到构建结果;请稍后到蒲公英后台确认"
107
+ end
108
+
109
+ def form_post!(url, fields)
110
+ uri = URI.parse(url)
111
+ request = Net::HTTP::Post.new(uri)
112
+ request["Content-Type"] = "application/x-www-form-urlencoded"
113
+ request.body = URI.encode_www_form(fields)
114
+ parse_json!(http(uri).request(request))
115
+ end
116
+
117
+ def parse_json!(response)
118
+ json = JSON.parse(response.body)
119
+ return json if response.is_a?(Net::HTTPSuccess) || json["code"].to_i == 0
120
+
121
+ raise UploadError, "蒲公英 API 请求失败(HTTP #{response.code}): #{json}"
122
+ rescue JSON::ParserError
123
+ raise UploadError, "蒲公英 API 返回了无法解析的响应(HTTP #{response.code}): #{response.body}"
124
+ end
125
+
126
+ def http(uri)
127
+ http = Net::HTTP.new(uri.host, uri.port)
128
+ http.use_ssl = uri.scheme == "https"
129
+ http.open_timeout = (@options[:open_timeout] || 30).to_i
130
+ http.read_timeout = (@options[:read_timeout] || 600).to_i
131
+ http
132
+ end
133
+
134
+ def env_value(name)
135
+ name && ENV[name]
136
+ end
137
+
138
+ def add_field(hash, key, value)
139
+ hash[key] = value.to_s unless value.nil? || value.to_s.empty?
140
+ end
141
+
142
+ def format_size(bytes)
143
+ return "#{bytes}B" if bytes < 1024
144
+ return format("%.1fKB", bytes / 1024.0) if bytes < 1024 * 1024
145
+ return format("%.1fMB", bytes / 1024.0 / 1024.0) if bytes < 1024 * 1024 * 1024
146
+
147
+ format("%.1fGB", bytes / 1024.0 / 1024.0 / 1024.0)
148
+ end
149
+
150
+ def deep_find(value, wanted_key)
151
+ return nil unless value.is_a?(Hash) || value.is_a?(Array)
152
+ return value[wanted_key] if value.is_a?(Hash) && value.key?(wanted_key)
153
+
154
+ (value.is_a?(Hash) ? value.values : value).each do |child|
155
+ found = deep_find(child, wanted_key)
156
+ return found unless found.nil?
157
+ end
158
+ nil
159
+ end
160
+ end
161
+
162
+ class FirUploader
163
+ FIR_API_HOST = "api.appmeta.cn"
164
+
165
+ def initialize(options = {})
166
+ @options = options
167
+ @api_token = options[:fir_api_token] || options[:api_token]
168
+ @api_token ||= ENV[options[:fir_api_token_env] || options[:api_token_env] || "FIR_API_TOKEN"]
169
+ @password = options[:fir_password] || options[:password]
170
+ @password ||= ENV[options[:fir_password_env] || options[:password_env] || "FIR_PASSWORD"]
171
+ if @api_token.to_s.empty?
172
+ raise ConfigurationError, "缺少 fir.im API Token,请使用 --fir-api-token 或 FIR_API_TOKEN"
173
+ end
174
+ end
175
+
176
+ def upload!(file)
177
+ raise ConfigurationError, "IPA/APK 文件不存在: #{file}" unless File.file?(file)
178
+
179
+ type = package_type(file)
180
+ metadata = package_metadata(file)
181
+ bundle_id = metadata[:bundle_id]
182
+ if bundle_id.to_s.empty?
183
+ raise ConfigurationError, "缺少应用 Bundle ID,请使用 --bundle-id 指定(fir.im 上传凭证需要该参数)"
184
+ end
185
+
186
+ puts "☁️ 获取 fir.im 上传凭证..."
187
+ app = request_credentials(type, bundle_id)
188
+ binary = app.dig("cert", "binary") || {}
189
+ unless [binary["key"], binary["token"], binary["upload_url"]].all? { |value| !value.to_s.empty? }
190
+ raise UploadError, "获取 fir.im 上传凭证失败: #{app}"
191
+ end
192
+
193
+ puts "☁️ 上传 #{File.basename(file)}(#{format_size(File.size(file))})到 fir.im..."
194
+ upload_binary!(file, binary, metadata, type)
195
+
196
+ icon = @options[:icon]
197
+ upload_icon!(icon, app.dig("cert", "icon")) if icon
198
+ update_access_password!(app["id"], @password) unless @password.to_s.empty?
199
+
200
+ short = app["short"]
201
+ {
202
+ "provider" => "fir",
203
+ "data" => {
204
+ "id" => app["id"],
205
+ "short" => short,
206
+ "name" => metadata[:app_name],
207
+ "bundle_id" => bundle_id,
208
+ "version" => metadata[:version],
209
+ "build" => metadata[:build],
210
+ "password_protected" => !@password.to_s.empty?,
211
+ "type" => type
212
+ },
213
+ "url" => short.to_s.empty? ? "https://fir.im/" : "https://fir.im/#{short}"
214
+ }
215
+ end
216
+
217
+ private
218
+
219
+ def package_type(file)
220
+ case File.extname(file).downcase
221
+ when ".ipa" then "ios"
222
+ when ".apk" then "android"
223
+ else
224
+ raise ConfigurationError, "fir.im 只支持 .ipa 或 .apk 文件: #{file}"
225
+ end
226
+ end
227
+
228
+ def package_metadata(file)
229
+ info = File.extname(file).casecmp(".ipa").zero? ? ipa_info(file) : {}
230
+ {
231
+ bundle_id: @options[:bundle_id] || info["CFBundleIdentifier"],
232
+ app_name: @options[:app_name] || info["CFBundleDisplayName"] || info["CFBundleName"] || File.basename(file, File.extname(file)),
233
+ version: @options[:app_version] || info["CFBundleShortVersionString"] || "1.0",
234
+ build: @options[:build_number] || info["CFBundleVersion"] || "1"
235
+ }
236
+ end
237
+
238
+ def ipa_info(file)
239
+ return {} unless Runner.which("unzip") && Runner.which("plutil")
240
+
241
+ entries, _, status = Open3.capture3("unzip", "-Z1", file)
242
+ return {} unless status.success?
243
+
244
+ plist_entry = entries.lines.map(&:strip).find { |entry| entry.match?(%r{\APayload/[^/]+\.app/Info\.plist\z}) }
245
+ return {} unless plist_entry
246
+
247
+ plist_data, _, plist_status = Open3.capture3("unzip", "-p", file, plist_entry)
248
+ return {} unless plist_status.success?
249
+
250
+ Tempfile.create(["appship-fir-info", ".plist"]) do |plist|
251
+ plist.binmode
252
+ plist.write(plist_data)
253
+ plist.flush
254
+ json, _, json_status = Open3.capture3("plutil", "-convert", "json", "-o", "-", plist.path)
255
+ return JSON.parse(json) if json_status.success?
256
+ end
257
+ {}
258
+ rescue JSON::ParserError, Errno::ENOENT
259
+ {}
260
+ end
261
+
262
+ def request_credentials(type, bundle_id)
263
+ response = post_json!("https://#{FIR_API_HOST}/apps", {
264
+ "type" => type,
265
+ "bundle_id" => bundle_id,
266
+ "api_token" => @api_token
267
+ })
268
+ response["data"].is_a?(Hash) ? response["data"] : response
269
+ end
270
+
271
+ def upload_binary!(file, certificate, metadata, type)
272
+ fields = {
273
+ "key" => certificate["key"],
274
+ "token" => certificate["token"],
275
+ "x:name" => metadata[:app_name],
276
+ "x:version" => metadata[:version],
277
+ "x:build" => metadata[:build]
278
+ }
279
+ if type == "ios"
280
+ fields["x:release_type"] = @options[:release_type] || "Adhoc"
281
+ end
282
+ add_field(fields, "x:changelog", @options[:description])
283
+ multipart_upload!(certificate["upload_url"], fields, file)
284
+ end
285
+
286
+ def upload_icon!(icon, certificate)
287
+ raise ConfigurationError, "fir.im 图标文件不存在: #{icon}" unless File.file?(icon)
288
+ unless certificate.is_a?(Hash) && [certificate["key"], certificate["token"], certificate["upload_url"]].all? { |value| !value.to_s.empty? }
289
+ raise UploadError, "获取 fir.im 图标上传凭证失败"
290
+ end
291
+
292
+ puts "☁️ 上传 fir.im 应用图标..."
293
+ multipart_upload!(certificate["upload_url"], {
294
+ "key" => certificate["key"],
295
+ "token" => certificate["token"]
296
+ }, icon)
297
+ end
298
+
299
+ def update_access_password!(app_id, password)
300
+ if app_id.to_s.empty?
301
+ raise UploadError, "fir.im 返回结果缺少应用 ID,无法设置访问密码"
302
+ end
303
+
304
+ puts "🔒 设置 fir.im 访客密码..."
305
+ uri = URI.parse("https://#{FIR_API_HOST}/apps/#{URI.encode_www_form_component(app_id)}")
306
+ request = Net::HTTP::Put.new(uri)
307
+ request["Content-Type"] = "application/x-www-form-urlencoded"
308
+ request.body = URI.encode_www_form("api_token" => @api_token, "passwd" => password)
309
+ response = http(uri).request(request)
310
+ json = JSON.parse(response.body)
311
+ return json if response.is_a?(Net::HTTPSuccess)
312
+
313
+ raise UploadError, "fir.im 访问密码设置失败(HTTP #{response.code}): #{json}"
314
+ rescue JSON::ParserError
315
+ raise UploadError, "fir.im 访问密码设置失败(HTTP #{response&.code}): #{response&.body}"
316
+ end
317
+
318
+ def post_json!(url, payload)
319
+ uri = URI.parse(url)
320
+ request = Net::HTTP::Post.new(uri)
321
+ request["Content-Type"] = "application/json"
322
+ request.body = JSON.generate(payload)
323
+ response = http(uri).request(request)
324
+ json = JSON.parse(response.body)
325
+ return json if response.is_a?(Net::HTTPSuccess)
326
+
327
+ raise UploadError, "fir.im API 请求失败(HTTP #{response.code}): #{json}"
328
+ rescue JSON::ParserError
329
+ raise UploadError, "fir.im API 返回了无法解析的响应(HTTP #{response&.code}): #{response&.body}"
330
+ end
331
+
332
+ def multipart_upload!(url, fields, file)
333
+ uri = URI.parse(url)
334
+ boundary = "----AppshipFirUpload#{SecureRandom.hex(12)}"
335
+ progress = UploadProgress.new(File.size(file))
336
+ progress.start
337
+ body = MultipartBody.new(fields, file, boundary, progress: progress.method(:update))
338
+ request = Net::HTTP::Post.new(uri)
339
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
340
+ request["Content-Length"] = body.length.to_s
341
+ request.body_stream = body
342
+ response = http(uri).request(request)
343
+ return true if response.is_a?(Net::HTTPSuccess)
344
+
345
+ raise UploadError, "fir.im 文件上传失败(HTTP #{response.code}): #{response.body}"
346
+ ensure
347
+ progress&.finish
348
+ end
349
+
350
+ def http(uri)
351
+ http = Net::HTTP.new(uri.host, uri.port)
352
+ http.use_ssl = uri.scheme == "https"
353
+ http.open_timeout = (@options[:open_timeout] || 30).to_i
354
+ http.read_timeout = (@options[:read_timeout] || 600).to_i
355
+ http
356
+ end
357
+
358
+ def add_field(hash, key, value)
359
+ hash[key] = value.to_s unless value.nil? || value.to_s.empty?
360
+ end
361
+
362
+ def format_size(bytes)
363
+ return "#{bytes}B" if bytes < 1024
364
+ return format("%.1fKB", bytes / 1024.0) if bytes < 1024 * 1024
365
+ return format("%.1fMB", bytes / 1024.0 / 1024.0) if bytes < 1024 * 1024 * 1024
366
+
367
+ format("%.1fGB", bytes / 1024.0 / 1024.0 / 1024.0)
368
+ end
369
+ end
370
+
371
+ class UploadProgress
372
+ BAR_WIDTH = 24
373
+
374
+ def initialize(total, output: $stdout)
375
+ @total = [total.to_i, 1].max
376
+ @output = output
377
+ @interactive = output.tty?
378
+ @last_percent = -1
379
+ @started = false
380
+ end
381
+
382
+ def start
383
+ @started = true
384
+ render(0)
385
+ end
386
+
387
+ def update(current, total = @total)
388
+ @total = [total.to_i, 1].max
389
+ percent = [[(current.to_i * 100.0 / @total).floor, 0].max, 100].min
390
+ return if percent == @last_percent
391
+
392
+ render(percent, current.to_i)
393
+ end
394
+
395
+ def finish
396
+ return unless @started
397
+
398
+ render(100, @total)
399
+ if @interactive
400
+ @output.print "\r\e[2K"
401
+ @output.puts "☁️ 上传完成(#{format_size(@total)})"
402
+ end
403
+ @output.flush
404
+ end
405
+
406
+ private
407
+
408
+ def render(percent, current = 0)
409
+ @last_percent = percent
410
+ if @interactive
411
+ filled = (BAR_WIDTH * percent / 100.0).round
412
+ bar = "█" * filled + "░" * (BAR_WIDTH - filled)
413
+ @output.print "\r☁️ 上传中 [#{bar}] #{format("%3d", percent)}% (#{format_size(current)}/#{format_size(@total)})"
414
+ elsif percent.zero? || percent == 100 || (percent % 5).zero?
415
+ @output.puts "☁️ 上传进度: #{percent}% (#{format_size(current)}/#{format_size(@total)})"
416
+ end
417
+ @output.flush
418
+ end
419
+
420
+ def format_size(bytes)
421
+ return "#{bytes}B" if bytes < 1024
422
+ return format("%.1fKB", bytes / 1024.0) if bytes < 1024 * 1024
423
+ return format("%.1fMB", bytes / 1024.0 / 1024.0) if bytes < 1024 * 1024 * 1024
424
+
425
+ format("%.1fGB", bytes / 1024.0 / 1024.0 / 1024.0)
426
+ end
427
+ end
428
+
429
+ class MultipartBody
430
+ def initialize(fields, file, boundary, progress: nil)
431
+ @progress = progress
432
+ @file_size = File.size(file)
433
+ @segments = []
434
+ fields.each do |name, value|
435
+ @segments << "--#{boundary}\r\n"
436
+ @segments << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
437
+ @segments << value.to_s
438
+ @segments << "\r\n"
439
+ end
440
+ @segments << "--#{boundary}\r\n"
441
+ @segments << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file)}\"\r\n"
442
+ @segments << "Content-Type: application/octet-stream\r\n\r\n"
443
+ @file = File.open(file, "rb")
444
+ @segments << @file
445
+ @segments << "\r\n--#{boundary}--\r\n"
446
+ @index = 0
447
+ @offset = 0
448
+ @length = @segments.sum { |segment| segment.respond_to?(:read) ? segment.size : segment.bytesize }
449
+ end
450
+
451
+ def length
452
+ @length
453
+ end
454
+
455
+ def read(length = nil, out_buffer = nil)
456
+ requested = length || 16 * 1024
457
+ result = out_buffer || +""
458
+ result.clear
459
+ while result.bytesize < requested && @index < @segments.length
460
+ segment = @segments[@index]
461
+ if segment.respond_to?(:read)
462
+ chunk = segment.read(requested - result.bytesize)
463
+ if chunk.nil? || chunk.empty?
464
+ segment.close unless segment.closed?
465
+ @index += 1
466
+ else
467
+ result << chunk
468
+ if segment.equal?(@file)
469
+ @progress&.call(@file.tell, @file_size)
470
+ end
471
+ end
472
+ else
473
+ remaining = segment.byteslice(@offset, requested - result.bytesize)
474
+ if remaining.nil? || remaining.empty?
475
+ @index += 1
476
+ @offset = 0
477
+ else
478
+ result << remaining
479
+ @offset += remaining.bytesize
480
+ if @offset >= segment.bytesize
481
+ @index += 1
482
+ @offset = 0
483
+ end
484
+ end
485
+ end
486
+ end
487
+ result.empty? ? nil : result
488
+ end
489
+ end
490
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ VERSION = "0.1.0"
5
+ end