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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/exe/appship +6 -0
- data/lib/appship/badger.rb +175 -0
- data/lib/appship/builder.rb +295 -0
- data/lib/appship/cache.rb +207 -0
- data/lib/appship/cli.rb +800 -0
- data/lib/appship/config.rb +75 -0
- data/lib/appship/errors.rb +8 -0
- data/lib/appship/ipa.rb +51 -0
- data/lib/appship/runner.rb +55 -0
- data/lib/appship/uploader.rb +490 -0
- data/lib/appship/version.rb +5 -0
- data/lib/appship.rb +25 -0
- data/test/test_cache.rb +146 -0
- data/test/test_cli.rb +22 -0
- data/test/test_config.rb +22 -0
- data/test/test_fir_uploader.rb +93 -0
- data/test/test_multipart_body.rb +33 -0
- metadata +61 -0
|
@@ -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
|
data/lib/appship/ipa.rb
ADDED
|
@@ -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
|