jpsclient 0.2.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/bin/jpsclient +28 -0
- data/lib/jpsclient/api/app_level.rb +138 -0
- data/lib/jpsclient/api/app_resource.rb +164 -0
- data/lib/jpsclient/api/app_resource_version.rb +52 -0
- data/lib/jpsclient/api/apple_account.rb +140 -0
- data/lib/jpsclient/api/application.rb +268 -0
- data/lib/jpsclient/api/application_category.rb +120 -0
- data/lib/jpsclient/api/application_design.rb +118 -0
- data/lib/jpsclient/api/application_income.rb +54 -0
- data/lib/jpsclient/api/application_sales.rb +52 -0
- data/lib/jpsclient/api/application_version.rb +77 -0
- data/lib/jpsclient/api/assets_category.rb +120 -0
- data/lib/jpsclient/api/bug.rb +140 -0
- data/lib/jpsclient/api/cert.rb +27 -0
- data/lib/jpsclient/api/client.rb +268 -0
- data/lib/jpsclient/api/collect.rb +52 -0
- data/lib/jpsclient/api/collection.rb +162 -0
- data/lib/jpsclient/api/commit_log.rb +104 -0
- data/lib/jpsclient/api/creative.rb +52 -0
- data/lib/jpsclient/api/custom_application.rb +230 -0
- data/lib/jpsclient/api/custom_application_web.rb +52 -0
- data/lib/jpsclient/api/document_text.rb +30 -0
- data/lib/jpsclient/api/experience.rb +143 -0
- data/lib/jpsclient/api/experience_category.rb +97 -0
- data/lib/jpsclient/api/fgui_export.rb +52 -0
- data/lib/jpsclient/api/file.rb +84 -0
- data/lib/jpsclient/api/game_assets.rb +140 -0
- data/lib/jpsclient/api/healthy.rb +30 -0
- data/lib/jpsclient/api/icon_and_snapshot.rb +54 -0
- data/lib/jpsclient/api/idea.rb +121 -0
- data/lib/jpsclient/api/jssdk.rb +30 -0
- data/lib/jpsclient/api/lark_bitable.rb +30 -0
- data/lib/jpsclient/api/lark_chat_group.rb +30 -0
- data/lib/jpsclient/api/lark_comment.rb +118 -0
- data/lib/jpsclient/api/lark_task.rb +140 -0
- data/lib/jpsclient/api/lark_task_list.rb +118 -0
- data/lib/jpsclient/api/lark_task_section.rb +74 -0
- data/lib/jpsclient/api/lark_user.rb +30 -0
- data/lib/jpsclient/api/lark_wiki_node.rb +30 -0
- data/lib/jpsclient/api/lark_wiki_space.rb +30 -0
- data/lib/jpsclient/api/lazy_client.rb +290 -0
- data/lib/jpsclient/api/login.rb +96 -0
- data/lib/jpsclient/api/m3u8.rb +30 -0
- data/lib/jpsclient/api/menu.rb +143 -0
- data/lib/jpsclient/api/modular_client.rb +228 -0
- data/lib/jpsclient/api/project.rb +46 -0
- data/lib/jpsclient/api/project_package.rb +249 -0
- data/lib/jpsclient/api/publisher.rb +165 -0
- data/lib/jpsclient/api/publisher_category.rb +142 -0
- data/lib/jpsclient/api/publisher_group.rb +118 -0
- data/lib/jpsclient/api/publisher_group_category.rb +120 -0
- data/lib/jpsclient/api/requirements.rb +143 -0
- data/lib/jpsclient/api/requirements_category.rb +97 -0
- data/lib/jpsclient/api/resource_category.rb +120 -0
- data/lib/jpsclient/api/role.rb +165 -0
- data/lib/jpsclient/api/simple_search.rb +162 -0
- data/lib/jpsclient/api/sketch.rb +74 -0
- data/lib/jpsclient/api/sketch_category.rb +97 -0
- data/lib/jpsclient/api/sov.rb +30 -0
- data/lib/jpsclient/api/statistics.rb +30 -0
- data/lib/jpsclient/api/store.rb +30 -0
- data/lib/jpsclient/api/survey.rb +143 -0
- data/lib/jpsclient/api/survey_category.rb +97 -0
- data/lib/jpsclient/api/tag.rb +142 -0
- data/lib/jpsclient/api/tool.rb +121 -0
- data/lib/jpsclient/api/tool_category.rb +120 -0
- data/lib/jpsclient/api/trending.rb +30 -0
- data/lib/jpsclient/api/ud_id.rb +118 -0
- data/lib/jpsclient/api/user.rb +99 -0
- data/lib/jpsclient/api/util.rb +30 -0
- data/lib/jpsclient/api/video_cover.rb +30 -0
- data/lib/jpsclient/api/webhook.rb +118 -0
- data/lib/jpsclient/api/workflow.rb +118 -0
- data/lib/jpsclient/auth/auth.rb +676 -0
- data/lib/jpsclient/auth/token.rb +209 -0
- data/lib/jpsclient/base/api_config.rb +225 -0
- data/lib/jpsclient/base/exception.rb +5 -0
- data/lib/jpsclient/http/http_client.rb +148 -0
- data/lib/jpsclient/upload/upload_client.rb +334 -0
- data/lib/jpsclient/upload/upload_config.rb +128 -0
- data/lib/jpsclient/upload/upload_progress.rb +73 -0
- data/lib/jpsclient/utils/aes.rb +49 -0
- data/lib/jpsclient/utils/logger.rb +38 -0
- data/lib/jpsclient/version.rb +3 -0
- data/lib/jpsclient.rb +34 -0
- metadata +269 -0
@@ -0,0 +1,676 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'webrick'
|
4
|
+
require 'json'
|
5
|
+
require 'net/http'
|
6
|
+
require 'uri'
|
7
|
+
require 'securerandom'
|
8
|
+
require 'digest'
|
9
|
+
require 'base64'
|
10
|
+
require 'fileutils'
|
11
|
+
require 'jpsclient/utils/aes'
|
12
|
+
require 'jpsclient/base/exception'
|
13
|
+
require 'jpsclient/utils/logger'
|
14
|
+
|
15
|
+
module JPSClient
|
16
|
+
|
17
|
+
# JPS 登录配置类
|
18
|
+
class LoginConfig
|
19
|
+
attr_accessor :client_id, :feishu_auth_url, :redirect_uri, :api_endpoint, :state, :server_port
|
20
|
+
attr_accessor :aes_key, :token_verify_endpoint
|
21
|
+
|
22
|
+
def initialize
|
23
|
+
# 所有配置必须从外部配置文件加载,不提供硬编码的默认值
|
24
|
+
@client_id = nil
|
25
|
+
@feishu_auth_url = nil
|
26
|
+
@redirect_uri = nil
|
27
|
+
@api_endpoint = nil
|
28
|
+
@state = 'client_login' # 仅此项可有默认值
|
29
|
+
@server_port = 8898 # 仅此项可有默认值
|
30
|
+
@aes_key = nil # AES 加密密钥,必需配置
|
31
|
+
@token_verify_endpoint = nil # Token 验证端点,必需配置
|
32
|
+
end
|
33
|
+
|
34
|
+
# 从 JSON 加载配置
|
35
|
+
def self.from_json(json_data)
|
36
|
+
config = new
|
37
|
+
return config if json_data.nil?
|
38
|
+
|
39
|
+
data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
|
40
|
+
|
41
|
+
# 加载所有配置字段
|
42
|
+
config.client_id = data['client_id'] if data['client_id']
|
43
|
+
config.feishu_auth_url = data['feishu_auth_url'] if data['feishu_auth_url']
|
44
|
+
config.redirect_uri = data['redirect_uri'] if data['redirect_uri']
|
45
|
+
config.api_endpoint = data['api_endpoint'] if data['api_endpoint']
|
46
|
+
config.state = data['state'] if data['state']
|
47
|
+
config.server_port = data['server_port'] if data['server_port']
|
48
|
+
config.token_verify_endpoint = data['token_verify_endpoint'] if data['token_verify_endpoint']
|
49
|
+
config.aes_key = data['aes_key'] if data['aes_key']
|
50
|
+
|
51
|
+
config
|
52
|
+
end
|
53
|
+
|
54
|
+
# 从配置文件加载
|
55
|
+
def self.from_file(file_path)
|
56
|
+
return new unless File.exist?(file_path)
|
57
|
+
from_json(File.read(file_path))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class Auth
|
62
|
+
attr_reader :access_token, :username, :expires_at
|
63
|
+
|
64
|
+
def initialize(config = nil)
|
65
|
+
# 只接受 LoginConfig 对象或默认配置
|
66
|
+
@config = config || LoginConfig.new
|
67
|
+
|
68
|
+
# 从配置对象获取值
|
69
|
+
@client_id = @config.client_id
|
70
|
+
@feishu_auth_url = @config.feishu_auth_url
|
71
|
+
@redirect_uri = @config.redirect_uri
|
72
|
+
@api_endpoint = @config.api_endpoint
|
73
|
+
@state = @config.state
|
74
|
+
@server_port = @config.server_port
|
75
|
+
|
76
|
+
@scope_list = [
|
77
|
+
'task:task:write',
|
78
|
+
'task:section:write',
|
79
|
+
'task:custom_field:write',
|
80
|
+
'task:tasklist:write'
|
81
|
+
]
|
82
|
+
|
83
|
+
@access_token = nil
|
84
|
+
@username = nil
|
85
|
+
@expires_at = nil
|
86
|
+
|
87
|
+
# 调试模式,通过环境变量控制
|
88
|
+
@verbose = ENV['PINDO_DEBUG'] == 'true'
|
89
|
+
end
|
90
|
+
|
91
|
+
# 主登录入口
|
92
|
+
def login
|
93
|
+
puts "🔐 需要登录 JPS..."
|
94
|
+
result = authorize_and_login
|
95
|
+
|
96
|
+
# 如果用户主动取消,返回特殊标识
|
97
|
+
return :user_cancelled if result == :user_cancelled
|
98
|
+
|
99
|
+
return result
|
100
|
+
end
|
101
|
+
|
102
|
+
# 启动授权流程(保留兼容性)
|
103
|
+
def authorize
|
104
|
+
login
|
105
|
+
end
|
106
|
+
|
107
|
+
# 获取用户名(登录后可用)
|
108
|
+
def get_username
|
109
|
+
@username
|
110
|
+
end
|
111
|
+
|
112
|
+
# 获取 token 数据(供 Client 使用)
|
113
|
+
def get_token_data
|
114
|
+
return nil unless @access_token
|
115
|
+
|
116
|
+
{
|
117
|
+
'token' => @access_token,
|
118
|
+
'username' => @username,
|
119
|
+
'expires_at' => @expires_at
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
123
|
+
# 完整的授权和登录流程
|
124
|
+
def authorize_and_login
|
125
|
+
# 构建授权 URL
|
126
|
+
authorization_uri = build_authorization_uri
|
127
|
+
puts "正在打开浏览器进行飞书 OAuth 授权..."
|
128
|
+
puts "\n授权 URL(如自动打开失败,请手动复制下面的链接到浏览器):"
|
129
|
+
puts "=" * 80
|
130
|
+
puts authorization_uri
|
131
|
+
puts "=" * 80
|
132
|
+
puts ""
|
133
|
+
|
134
|
+
# 在浏览器中打开授权 URL
|
135
|
+
open_browser(authorization_uri)
|
136
|
+
|
137
|
+
# 启动本地服务器处理回调
|
138
|
+
code = start_callback_server
|
139
|
+
|
140
|
+
# 如果自动获取失败,提示用户手动输入
|
141
|
+
if code.nil?
|
142
|
+
loop do
|
143
|
+
puts "\n自动获取授权码失败,请选择:"
|
144
|
+
puts "1. 输入授权码 (直接复制 'code=' 后面的内容)"
|
145
|
+
puts "2. 输入完整回调 URL"
|
146
|
+
puts "3. 重新打开授权网页"
|
147
|
+
puts "4. 退出"
|
148
|
+
print "> "
|
149
|
+
|
150
|
+
begin
|
151
|
+
choice = STDIN.gets&.chomp
|
152
|
+
|
153
|
+
# 处理 Ctrl+C 中断
|
154
|
+
if choice.nil?
|
155
|
+
puts "\n用户中断操作"
|
156
|
+
return :user_cancelled
|
157
|
+
end
|
158
|
+
|
159
|
+
case choice
|
160
|
+
when "1"
|
161
|
+
puts "请输入授权码:"
|
162
|
+
print "> "
|
163
|
+
code_input = STDIN.gets&.chomp
|
164
|
+
if code_input.nil?
|
165
|
+
puts "用户中断操作"
|
166
|
+
return :user_cancelled
|
167
|
+
elsif !code_input.empty?
|
168
|
+
code = code_input
|
169
|
+
break
|
170
|
+
else
|
171
|
+
puts "授权码不能为空,请重新选择"
|
172
|
+
end
|
173
|
+
when "2"
|
174
|
+
puts "请输入完整回调 URL:"
|
175
|
+
print "> "
|
176
|
+
url_input = STDIN.gets&.chomp
|
177
|
+
if url_input.nil?
|
178
|
+
puts "用户中断操作"
|
179
|
+
return :user_cancelled
|
180
|
+
elsif url_input.start_with?("http")
|
181
|
+
# 尝试从 URL 中提取 code
|
182
|
+
begin
|
183
|
+
uri = URI(url_input)
|
184
|
+
query_params = URI.decode_www_form(uri.query || '').to_h
|
185
|
+
code = query_params['code']
|
186
|
+
if code
|
187
|
+
puts "✓ 从 URL 中成功提取授权码"
|
188
|
+
break
|
189
|
+
else
|
190
|
+
puts "✗ URL 中没有找到授权码,请重新选择"
|
191
|
+
end
|
192
|
+
rescue => e
|
193
|
+
puts "✗ 无法从 URL 中提取授权码: #{e.message}"
|
194
|
+
end
|
195
|
+
else
|
196
|
+
puts "✗ 无效的 URL 格式,请重新选择"
|
197
|
+
end
|
198
|
+
when "3"
|
199
|
+
# 重新打开授权网页
|
200
|
+
puts "正在重新打开授权网页..."
|
201
|
+
open_browser(authorization_uri)
|
202
|
+
# 重新启动服务器尝试获取授权码
|
203
|
+
code = start_callback_server
|
204
|
+
if code
|
205
|
+
break
|
206
|
+
end
|
207
|
+
# 如果还是失败,继续循环让用户选择
|
208
|
+
when "4"
|
209
|
+
puts "已退出授权流程"
|
210
|
+
return :user_cancelled
|
211
|
+
else
|
212
|
+
puts "无效的选择,请输入 1-4 之间的数字"
|
213
|
+
end
|
214
|
+
rescue Interrupt
|
215
|
+
puts "\n\n用户中断操作"
|
216
|
+
return :user_cancelled
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
if code
|
222
|
+
if exchange_code_for_token(code)
|
223
|
+
puts "✓ JPS 登录成功!用户名: #{@username}"
|
224
|
+
return true
|
225
|
+
end
|
226
|
+
return false
|
227
|
+
else
|
228
|
+
puts "✗ 授权失败"
|
229
|
+
return false
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# 检查 token 是否有效
|
234
|
+
def validate_token(token = nil)
|
235
|
+
token_to_check = token || @access_token
|
236
|
+
return false unless token_to_check
|
237
|
+
|
238
|
+
# 检查过期时间
|
239
|
+
if @expires_at && Time.now.to_i > @expires_at
|
240
|
+
puts "Token 已过期,需要重新登录"
|
241
|
+
return false
|
242
|
+
end
|
243
|
+
|
244
|
+
# 简单验证(实际项目中可以调用 API 验证)
|
245
|
+
return true
|
246
|
+
end
|
247
|
+
|
248
|
+
# 验证Pgyer令牌(保留兼容性)
|
249
|
+
def validate_pgyer_token(token = nil, expires_at = nil)
|
250
|
+
validate_token(token)
|
251
|
+
end
|
252
|
+
|
253
|
+
# 使用授权码换取 token
|
254
|
+
def exchange_code_for_token(code)
|
255
|
+
begin
|
256
|
+
request_data = {
|
257
|
+
'code' => code,
|
258
|
+
'redirectUri' => @redirect_uri,
|
259
|
+
'scope' => @scope_list.join(' ')
|
260
|
+
}
|
261
|
+
|
262
|
+
uri = URI(@api_endpoint)
|
263
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
264
|
+
http.use_ssl = (uri.scheme == 'https')
|
265
|
+
|
266
|
+
request = Net::HTTP::Post.new(uri)
|
267
|
+
request['Content-Type'] = 'application/json'
|
268
|
+
request.body = request_data.to_json
|
269
|
+
|
270
|
+
puts "正在请求 JPS API: #{@api_endpoint}" if @verbose
|
271
|
+
response = http.request(request)
|
272
|
+
|
273
|
+
puts "API 响应状态码: #{response.code}" if @verbose
|
274
|
+
|
275
|
+
if response.body
|
276
|
+
result = JSON.parse(response.body)
|
277
|
+
puts "API 响应: #{result.inspect}" if @verbose
|
278
|
+
|
279
|
+
if result['meta'] && result['meta']['code'] == 200 && result['data']
|
280
|
+
data = result['data']
|
281
|
+
@access_token = data['token'] if data['token']
|
282
|
+
@username = data['username'] if data['username']
|
283
|
+
# 设置过期时间为 6 天后
|
284
|
+
@expires_at = Time.now.to_i + 6 * 24 * 60 * 60
|
285
|
+
|
286
|
+
return true if @access_token
|
287
|
+
else
|
288
|
+
error_msg = result['meta'] && result['meta']['message'] ? result['meta']['message'] : '未知错误'
|
289
|
+
puts "JPS 登录失败: #{error_msg}"
|
290
|
+
end
|
291
|
+
else
|
292
|
+
puts "API 返回空响应" if @verbose
|
293
|
+
end
|
294
|
+
|
295
|
+
return false
|
296
|
+
rescue => e
|
297
|
+
puts "请求 JPS API 失败: #{e.class} - #{e.message}"
|
298
|
+
return false
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
|
303
|
+
private
|
304
|
+
|
305
|
+
# 获取跨平台的 null 设备路径
|
306
|
+
def get_null_device
|
307
|
+
case RbConfig::CONFIG['host_os']
|
308
|
+
when /mswin|mingw|cygwin/
|
309
|
+
'NUL'
|
310
|
+
else
|
311
|
+
'/dev/null'
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
|
316
|
+
# 构建授权URI
|
317
|
+
def build_authorization_uri
|
318
|
+
uri = URI(@feishu_auth_url)
|
319
|
+
|
320
|
+
params = {
|
321
|
+
'client_id' => @client_id,
|
322
|
+
'redirect_uri' => @redirect_uri,
|
323
|
+
'response_type' => 'code',
|
324
|
+
'state' => @state,
|
325
|
+
'scope' => @scope_list.join(' ')
|
326
|
+
}
|
327
|
+
|
328
|
+
uri.query = URI.encode_www_form(params)
|
329
|
+
uri.to_s
|
330
|
+
end
|
331
|
+
|
332
|
+
# 在浏览器中打开URL
|
333
|
+
def open_browser(url)
|
334
|
+
case RbConfig::CONFIG['host_os']
|
335
|
+
when /mswin|mingw|cygwin/
|
336
|
+
# Windows: 使用双引号包围URL避免参数解析问题
|
337
|
+
system("start \"\" \"#{url}\"")
|
338
|
+
when /darwin/
|
339
|
+
system("open", url)
|
340
|
+
when /linux|bsd/
|
341
|
+
system("xdg-open", url)
|
342
|
+
else
|
343
|
+
puts "无法自动打开浏览器,请手动访问: #{url}"
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# 启动本地服务器处理回调
|
348
|
+
def start_callback_server
|
349
|
+
code = nil
|
350
|
+
|
351
|
+
# 检查并处理端口占用
|
352
|
+
unless ensure_port_available(@server_port)
|
353
|
+
puts "✗ 无法使用端口 #{@server_port},登录失败"
|
354
|
+
return nil
|
355
|
+
end
|
356
|
+
|
357
|
+
puts "启动本地服务器,监听端口 #{@server_port}..."
|
358
|
+
puts "提示:按 Ctrl+C 可以中断并获得更多选择"
|
359
|
+
puts "🔄 正在使用飞书身份登录 JPS..."
|
360
|
+
|
361
|
+
begin
|
362
|
+
server = WEBrick::HTTPServer.new(
|
363
|
+
Port: @server_port,
|
364
|
+
BindAddress: '127.0.0.1',
|
365
|
+
Logger: WEBrick::Log.new(get_null_device),
|
366
|
+
AccessLog: []
|
367
|
+
)
|
368
|
+
rescue Errno::EADDRINUSE
|
369
|
+
puts "✗ 端口 #{@server_port} 仍被占用,无法启动服务器"
|
370
|
+
return nil
|
371
|
+
rescue Errno::ENOENT
|
372
|
+
puts "✗ 启动服务器失败: 系统找不到指定的路径或文件"
|
373
|
+
return nil
|
374
|
+
rescue => e
|
375
|
+
puts "✗ 启动服务器失败: #{e.message}"
|
376
|
+
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
377
|
+
puts " 提示: 如果是Windows系统,请确保防火墙允许Ruby访问网络"
|
378
|
+
end
|
379
|
+
return nil
|
380
|
+
end
|
381
|
+
|
382
|
+
# 处理根路径的请求
|
383
|
+
server.mount_proc '/' do |req, res|
|
384
|
+
begin
|
385
|
+
# 解析请求参数
|
386
|
+
query_params = URI.decode_www_form(req.query_string || '').to_h
|
387
|
+
puts "接收到回调请求,参数: #{query_params.inspect}" if @verbose
|
388
|
+
|
389
|
+
if query_params['error']
|
390
|
+
puts "授权错误: #{query_params['error']}" if @verbose
|
391
|
+
res.content_type = "text/html; charset=UTF-8"
|
392
|
+
res.body = build_error_page(query_params['error'])
|
393
|
+
elsif query_params['code']
|
394
|
+
code = query_params['code']
|
395
|
+
# 静默处理,避免重复输出
|
396
|
+
res.content_type = "text/html; charset=UTF-8"
|
397
|
+
res.body = build_success_page
|
398
|
+
else
|
399
|
+
# 忽略没有参数的请求(如 favicon.ico 等)
|
400
|
+
res.content_type = "text/html; charset=UTF-8"
|
401
|
+
res.body = "等待授权中..."
|
402
|
+
end
|
403
|
+
|
404
|
+
# 4秒后关闭服务器
|
405
|
+
Thread.new do
|
406
|
+
sleep 4
|
407
|
+
server.shutdown
|
408
|
+
end
|
409
|
+
rescue => e
|
410
|
+
puts "处理请求时出错: #{e.class} - #{e.message}" if @verbose
|
411
|
+
res.content_type = "text/html; charset=UTF-8"
|
412
|
+
res.body = build_error_page("处理请求时出错")
|
413
|
+
|
414
|
+
Thread.new do
|
415
|
+
sleep 4
|
416
|
+
server.shutdown
|
417
|
+
end
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
# 捕获 Ctrl+C
|
422
|
+
trap('INT') { server.shutdown }
|
423
|
+
|
424
|
+
# 在线程中运行服务器,最多等待 3 分钟
|
425
|
+
thread = Thread.new { server.start }
|
426
|
+
begin
|
427
|
+
thread.join(180) # 最多等待 3 分钟
|
428
|
+
rescue => e
|
429
|
+
puts "服务器等待超时"
|
430
|
+
server.shutdown
|
431
|
+
end
|
432
|
+
|
433
|
+
code
|
434
|
+
end
|
435
|
+
|
436
|
+
# 确保端口可用,处理端口占用问题
|
437
|
+
def ensure_port_available(port)
|
438
|
+
return true unless port_occupied?(port)
|
439
|
+
|
440
|
+
puts "⚠️ 端口 #{port} 被占用,正在检查占用进程..."
|
441
|
+
|
442
|
+
# 获取占用端口的进程信息
|
443
|
+
process_info = get_port_process_info(port)
|
444
|
+
|
445
|
+
if process_info.nil?
|
446
|
+
puts "✗ 无法获取端口占用信息"
|
447
|
+
return false
|
448
|
+
end
|
449
|
+
|
450
|
+
puts "占用进程信息:"
|
451
|
+
puts " PID: #{process_info[:pid]}"
|
452
|
+
puts " 进程名: #{process_info[:name]}"
|
453
|
+
puts " 命令: #{process_info[:command]}"
|
454
|
+
|
455
|
+
# 检查是否是自己的进程(可能是之前未正常关闭的实例)
|
456
|
+
if process_info[:name].include?('ruby') && (process_info[:command].include?('pindo') || process_info[:command].include?('jps'))
|
457
|
+
puts "检测到可能是 Pindo/JPS 之前的遗留进程"
|
458
|
+
if ask_user_kill_process?
|
459
|
+
return kill_process_by_pid(process_info[:pid])
|
460
|
+
end
|
461
|
+
else
|
462
|
+
puts "端口被其他应用程序占用"
|
463
|
+
if ask_user_kill_process?
|
464
|
+
return kill_process_by_pid(process_info[:pid])
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
return false
|
469
|
+
end
|
470
|
+
|
471
|
+
# 检查端口是否被占用
|
472
|
+
def port_occupied?(port)
|
473
|
+
require 'socket'
|
474
|
+
|
475
|
+
begin
|
476
|
+
server = TCPServer.new('127.0.0.1', port)
|
477
|
+
server.close
|
478
|
+
false # 端口未被占用
|
479
|
+
rescue Errno::EADDRINUSE
|
480
|
+
true # 端口被占用
|
481
|
+
rescue => e
|
482
|
+
puts "⚠️ 检查端口时出错: #{e.message}"
|
483
|
+
true # 出错时假设端口被占用,避免冲突
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
# 获取占用指定端口的进程信息
|
488
|
+
def get_port_process_info(port)
|
489
|
+
begin
|
490
|
+
# macOS/Linux 使用 lsof 命令
|
491
|
+
if system('which lsof > /dev/null 2>&1')
|
492
|
+
output = `lsof -ti :#{port} 2>/dev/null`.strip
|
493
|
+
return nil if output.empty?
|
494
|
+
|
495
|
+
pid = output.split("\n").first
|
496
|
+
return nil if pid.nil? || pid.empty?
|
497
|
+
|
498
|
+
# 获取进程详细信息
|
499
|
+
ps_output = `ps -p #{pid} -o pid,comm,args 2>/dev/null`.lines
|
500
|
+
return nil if ps_output.length < 2
|
501
|
+
|
502
|
+
process_line = ps_output[1].strip
|
503
|
+
parts = process_line.split(/\s+/, 3)
|
504
|
+
|
505
|
+
return {
|
506
|
+
pid: parts[0],
|
507
|
+
name: parts[1] || 'unknown',
|
508
|
+
command: parts[2] || 'unknown'
|
509
|
+
}
|
510
|
+
end
|
511
|
+
|
512
|
+
# Windows 使用 netstat 命令
|
513
|
+
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
514
|
+
output = `netstat -ano | findstr :#{port}`.strip
|
515
|
+
return nil if output.empty?
|
516
|
+
|
517
|
+
lines = output.split("\n")
|
518
|
+
listening_line = lines.find { |line| line.include?('LISTENING') }
|
519
|
+
return nil unless listening_line
|
520
|
+
|
521
|
+
pid = listening_line.split.last
|
522
|
+
return nil if pid.nil? || pid.empty?
|
523
|
+
|
524
|
+
# 获取进程名称
|
525
|
+
task_output = `tasklist /FI "PID eq #{pid}" /FO CSV /NH 2>nul`.strip
|
526
|
+
if !task_output.empty?
|
527
|
+
process_name = task_output.split(',').first.gsub('"', '')
|
528
|
+
return {
|
529
|
+
pid: pid,
|
530
|
+
name: process_name,
|
531
|
+
command: process_name
|
532
|
+
}
|
533
|
+
end
|
534
|
+
end
|
535
|
+
|
536
|
+
return nil
|
537
|
+
rescue => e
|
538
|
+
puts "获取进程信息时出错: #{e.message}"
|
539
|
+
return nil
|
540
|
+
end
|
541
|
+
end
|
542
|
+
|
543
|
+
# 询问用户是否终止占用端口的进程
|
544
|
+
def ask_user_kill_process?
|
545
|
+
puts "\n是否终止占用端口的进程?"
|
546
|
+
puts " y/yes - 终止进程并继续 (可能影响其他应用)"
|
547
|
+
puts " n/no - 取消登录 (默认)"
|
548
|
+
print "> "
|
549
|
+
|
550
|
+
begin
|
551
|
+
response = STDIN.gets&.chomp&.downcase
|
552
|
+
return ['y', 'yes'].include?(response)
|
553
|
+
rescue Interrupt
|
554
|
+
puts "\n\n用户中断操作"
|
555
|
+
return false
|
556
|
+
rescue => e
|
557
|
+
puts "无法读取用户输入: #{e.message}"
|
558
|
+
return false
|
559
|
+
end
|
560
|
+
end
|
561
|
+
|
562
|
+
# 根据 PID 终止进程
|
563
|
+
def kill_process_by_pid(pid)
|
564
|
+
return false if pid.nil? || pid.empty?
|
565
|
+
|
566
|
+
begin
|
567
|
+
puts "正在终止进程 #{pid}..."
|
568
|
+
|
569
|
+
# 跨平台的进程终止命令
|
570
|
+
if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
571
|
+
# Windows
|
572
|
+
success = system("taskkill /PID #{pid} /F >nul 2>&1")
|
573
|
+
else
|
574
|
+
# macOS/Linux - 先尝试优雅终止,再强制终止
|
575
|
+
success = system("kill -TERM #{pid} 2>/dev/null")
|
576
|
+
sleep(2)
|
577
|
+
# 检查进程是否还存在
|
578
|
+
if system("kill -0 #{pid} 2>/dev/null")
|
579
|
+
puts "优雅终止失败,使用强制终止..."
|
580
|
+
success = system("kill -KILL #{pid} 2>/dev/null")
|
581
|
+
end
|
582
|
+
end
|
583
|
+
|
584
|
+
if success
|
585
|
+
puts "✓ 进程 #{pid} 已终止"
|
586
|
+
sleep(1) # 等待端口释放
|
587
|
+
|
588
|
+
# 再次检查端口是否可用
|
589
|
+
unless port_occupied?(@server_port)
|
590
|
+
puts "✓ 端口 #{@server_port} 现在可用"
|
591
|
+
return true
|
592
|
+
else
|
593
|
+
puts "⚠️ 端口仍被占用,可能需要等待更长时间"
|
594
|
+
return false
|
595
|
+
end
|
596
|
+
else
|
597
|
+
puts "✗ 终止进程失败,可能需要管理员权限"
|
598
|
+
return false
|
599
|
+
end
|
600
|
+
rescue => e
|
601
|
+
puts "终止进程时出错: #{e.message}"
|
602
|
+
return false
|
603
|
+
end
|
604
|
+
end
|
605
|
+
|
606
|
+
# 构建成功页面
|
607
|
+
def build_success_page
|
608
|
+
<<~HTML
|
609
|
+
<!DOCTYPE html>
|
610
|
+
<html>
|
611
|
+
<head>
|
612
|
+
<meta charset="UTF-8">
|
613
|
+
<title>JPS 授权成功</title>
|
614
|
+
<style>
|
615
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
|
616
|
+
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
617
|
+
.success { color: #4CAF50; margin-bottom: 20px; }
|
618
|
+
.countdown { font-weight: bold; color: #FF5722; }
|
619
|
+
.close-btn { background: #4CAF50; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
|
620
|
+
</style>
|
621
|
+
<script>
|
622
|
+
var secondsLeft = 3;
|
623
|
+
function updateCountdown() {
|
624
|
+
document.getElementById('countdown').innerText = secondsLeft;
|
625
|
+
if (secondsLeft <= 0) {
|
626
|
+
window.close();
|
627
|
+
} else {
|
628
|
+
secondsLeft -= 1;
|
629
|
+
setTimeout(updateCountdown, 1000);
|
630
|
+
}
|
631
|
+
}
|
632
|
+
window.onload = function() { updateCountdown(); }
|
633
|
+
</script>
|
634
|
+
</head>
|
635
|
+
<body>
|
636
|
+
<div class="container">
|
637
|
+
<h1 class="success">✓ JPS 授权成功!</h1>
|
638
|
+
<p>已获取授权码,正在返回命令行...</p>
|
639
|
+
<p>此窗口将在 <span id="countdown" class="countdown">3</span> 秒后自动关闭</p>
|
640
|
+
<button class="close-btn" onclick="window.close()">立即关闭</button>
|
641
|
+
</div>
|
642
|
+
</body>
|
643
|
+
</html>
|
644
|
+
HTML
|
645
|
+
end
|
646
|
+
|
647
|
+
# 构建错误页面
|
648
|
+
def build_error_page(error_msg)
|
649
|
+
<<~HTML
|
650
|
+
<!DOCTYPE html>
|
651
|
+
<html>
|
652
|
+
<head>
|
653
|
+
<meta charset="UTF-8">
|
654
|
+
<title>JPS 授权失败</title>
|
655
|
+
<style>
|
656
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
|
657
|
+
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
658
|
+
.error { color: #f44336; margin-bottom: 20px; }
|
659
|
+
.close-btn { background: #f44336; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
|
660
|
+
</style>
|
661
|
+
</head>
|
662
|
+
<body>
|
663
|
+
<div class="container">
|
664
|
+
<h1 class="error">✗ JPS 授权失败</h1>
|
665
|
+
<p>错误信息: #{error_msg}</p>
|
666
|
+
<p>请返回命令行重试</p>
|
667
|
+
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
668
|
+
</div>
|
669
|
+
</body>
|
670
|
+
</html>
|
671
|
+
HTML
|
672
|
+
end
|
673
|
+
|
674
|
+
end
|
675
|
+
|
676
|
+
end
|