@huoye/app-ship 0.2.1 → 0.3.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.
- package/README.md +172 -139
- package/browser-extension/capture-fields.js +195 -0
- package/browser-extension/manifest.json +8 -0
- package/browser-extension/popup.html +152 -0
- package/browser-extension/popup.js +312 -0
- package/dist/browser-native.js +2031 -0
- package/dist/chunk-PUSXETML.js +21041 -0
- package/dist/cli.js +4936 -1632
- package/dist/index.cjs +7457 -4068
- package/dist/index.d.cts +1309 -531
- package/dist/index.d.ts +1309 -531
- package/dist/index.js +7397 -4060
- package/dist/mcp-tools-PTJDFF2T.js +8703 -0
- package/dist/mcp.js +15 -0
- package/dist/stdio-BXBWA2I3.js +514 -0
- package/docs/browser-credentials.md +178 -0
- package/package.json +26 -8
- package/scripts/register-browser-host.mjs +110 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// 采集当前可见的已有凭据;华为开发者级客户端与蒲公英 API Key 使用已核对的页面边界,秘密不进入描述结果。
|
|
2
|
+
/**
|
|
3
|
+
* 检查页面可采集的公开选项,或读取用户明确选择的一行已有凭据;不能创建、显露或重置密钥。
|
|
4
|
+
* @param platform - 当前任务的商店标识;华为/鸿蒙与蒲公英启用已核对的新版凭据结构。
|
|
5
|
+
* @param options - 可选采集参数;inspectOnly 为 true 时仅返回公开选项;新版采集要求 choiceKey 与 choiceLabel 同时匹配。
|
|
6
|
+
* @returns 检查模式返回公开 choices/requiresSelection;采集模式仅向本机管道返回认证字段。
|
|
7
|
+
*/
|
|
8
|
+
export function visibleFields(platform, options = {}) {
|
|
9
|
+
/**
|
|
10
|
+
* 检查节点及其祖先是否可见;密码输入框、隐藏内容不参与采集。
|
|
11
|
+
* @param node - 当前文档或已核对同源框架中的节点;缺失节点返回 false。
|
|
12
|
+
*/
|
|
13
|
+
const visible = (node) => {
|
|
14
|
+
if (!node || node.getClientRects().length === 0 || node.type === 'password') return false
|
|
15
|
+
for (let current = node; current; current = current.parentElement) {
|
|
16
|
+
const style = getComputedStyle(current)
|
|
17
|
+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0')
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 将可见字段标签规范化为匹配键,不处理字段值。
|
|
24
|
+
* @param value - 标签或列标题;缺省值视为空标签。
|
|
25
|
+
*/
|
|
26
|
+
const normalize = (value) =>
|
|
27
|
+
String(value ?? '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[\s_:\-:]/gu, '')
|
|
30
|
+
const modernHuawei =
|
|
31
|
+
['huawei', 'harmony'].includes(platform) &&
|
|
32
|
+
/^\/consumer\/(?:cn|en)\/hdc-console\/(?:index\.html)?$/u.test(
|
|
33
|
+
globalThis.location?.pathname || '',
|
|
34
|
+
)
|
|
35
|
+
if (modernHuawei) {
|
|
36
|
+
const documents = [document]
|
|
37
|
+
// 真实新版页面把表格放在此同源框架中;不读取其它框架、登录表单或跨域内容。
|
|
38
|
+
for (const frame of document.querySelectorAll('iframe')) {
|
|
39
|
+
if (!visible(frame)) continue
|
|
40
|
+
const url = new URL(frame.src, location.href)
|
|
41
|
+
if (
|
|
42
|
+
url.origin !== location.origin ||
|
|
43
|
+
!/^\/consumer\/(?:cn|en)\/consoleIframe\/api\/credentials\/1$/u.test(url.pathname)
|
|
44
|
+
)
|
|
45
|
+
continue
|
|
46
|
+
try {
|
|
47
|
+
if (frame.contentDocument) documents.push(frame.contentDocument)
|
|
48
|
+
} catch {
|
|
49
|
+
/* 跨域或不可访问框架不参与采集。 */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const scoped = documents.filter((root) =>
|
|
53
|
+
Array.from(root.querySelectorAll('[role="tab"][aria-selected="true"]')).some(
|
|
54
|
+
(tab) => visible(tab) && normalize(tab.textContent) === '开发者级凭证',
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
if (!scoped.length) throw new Error('huawei_developer_credentials_required')
|
|
58
|
+
const rows = []
|
|
59
|
+
const visited = new Set()
|
|
60
|
+
for (const root of scoped) {
|
|
61
|
+
for (const table of root.querySelectorAll('table')) {
|
|
62
|
+
if (!visible(table)) continue
|
|
63
|
+
// Ant Design 将表头和正文分为两个 table;nz-table 是同一业务表格的共同边界。
|
|
64
|
+
const container = table.closest('nz-table') || table
|
|
65
|
+
if (visited.has(container)) continue
|
|
66
|
+
visited.add(container)
|
|
67
|
+
const headers = Array.from(container.querySelectorAll('thead th')).map((node) =>
|
|
68
|
+
normalize(node.textContent),
|
|
69
|
+
)
|
|
70
|
+
const nameIndex = headers.indexOf('凭证名称')
|
|
71
|
+
const idIndex = headers.indexOf('凭证id')
|
|
72
|
+
const secretIndex = headers.indexOf('密钥')
|
|
73
|
+
const createdIndex = headers.indexOf('创建时间')
|
|
74
|
+
if (nameIndex < 0 || idIndex < 0 || secretIndex < 0 || !headers.includes('角色')) continue
|
|
75
|
+
for (const row of container.querySelectorAll('tbody tr')) {
|
|
76
|
+
if (!visible(row) || row.cells.length !== headers.length) continue
|
|
77
|
+
const name = row.cells[nameIndex].textContent.trim()
|
|
78
|
+
if (!name) continue
|
|
79
|
+
const created = createdIndex < 0 ? '' : row.cells[createdIndex].textContent.trim()
|
|
80
|
+
const label = [name, created]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' · ')
|
|
83
|
+
.replace(/[\r\n\t]/gu, ' ')
|
|
84
|
+
.slice(0, 200)
|
|
85
|
+
rows.push({
|
|
86
|
+
key: String(rows.length),
|
|
87
|
+
label,
|
|
88
|
+
id: row.cells[idIndex],
|
|
89
|
+
secret: row.cells[secretIndex],
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!rows.length) throw new Error('huawei_api_client_missing')
|
|
95
|
+
if (options.inspectOnly)
|
|
96
|
+
return {
|
|
97
|
+
requiresSelection: true,
|
|
98
|
+
choices: rows.map((row) => ({ key: row.key, label: row.label })),
|
|
99
|
+
}
|
|
100
|
+
const selected = rows.find(
|
|
101
|
+
(row) => row.key === options.choiceKey && row.label === options.choiceLabel,
|
|
102
|
+
)
|
|
103
|
+
if (!selected) throw new Error('credential_selection_changed')
|
|
104
|
+
/**
|
|
105
|
+
* 只读取选定单元格的明确可见值,拒绝遮盖文本、操作文案及多个候选。
|
|
106
|
+
* @param cell - 已按官方列标题定位的凭证 ID 或密钥单元格。
|
|
107
|
+
* @param id - 读取凭证 ID 时为 true,要求数字 ID;密钥只接受单个完整的令牌文本。
|
|
108
|
+
*/
|
|
109
|
+
const scalar = (cell, id) => {
|
|
110
|
+
const nodes = Array.from(cell.querySelectorAll('input,textarea,code,.key-id-text')).filter(
|
|
111
|
+
visible,
|
|
112
|
+
)
|
|
113
|
+
const values = (nodes.length ? nodes : [cell])
|
|
114
|
+
.filter(visible)
|
|
115
|
+
.map((node) => String(node.value ?? node.textContent).trim())
|
|
116
|
+
const valid = [
|
|
117
|
+
...new Set(
|
|
118
|
+
values.filter((value) =>
|
|
119
|
+
id ? /^\d{5,128}$/u.test(value) : /^[A-Za-z0-9._~+/=-]{8,8192}$/u.test(value),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
if (valid.length !== 1) throw new Error('credentials_not_visible')
|
|
124
|
+
return valid[0]
|
|
125
|
+
}
|
|
126
|
+
return { clientId: scalar(selected.id, true), clientSecret: scalar(selected.secret, false) }
|
|
127
|
+
}
|
|
128
|
+
if (platform === 'pgyer' && /^\/dashboard\/?$/u.test(globalThis.location?.pathname || '')) {
|
|
129
|
+
// 新版 API Key 与 User Key 分属独立卡片;只定位 API Key 的复制按钮及同级可见 code,不触发复制或重置。
|
|
130
|
+
const cards = Array.from(document.querySelectorAll('button[aria-label="复制API Key"]'))
|
|
131
|
+
.filter(visible)
|
|
132
|
+
.map((button) => button.parentElement)
|
|
133
|
+
.filter(
|
|
134
|
+
(card) =>
|
|
135
|
+
visible(card) &&
|
|
136
|
+
card.children[0]?.tagName === 'DIV' &&
|
|
137
|
+
visible(card.children[0]) &&
|
|
138
|
+
normalize(card.children[0].textContent) === 'apikey',
|
|
139
|
+
)
|
|
140
|
+
if (cards.length !== 1) throw new Error('pgyer_api_key_required')
|
|
141
|
+
const values = Array.from(cards[0].children).filter(
|
|
142
|
+
(node) => node.tagName === 'CODE' && visible(node) && node.children.length === 0,
|
|
143
|
+
)
|
|
144
|
+
if (values.length !== 1) throw new Error('pgyer_api_key_required')
|
|
145
|
+
if (options.inspectOnly) return { requiresSelection: false, choices: [] }
|
|
146
|
+
const apiKey = values[0].textContent.trim()
|
|
147
|
+
if (!/^[A-Za-z0-9._~+/=-]{8,8192}$/u.test(apiKey)) throw new Error('credentials_not_visible')
|
|
148
|
+
return { apiKey }
|
|
149
|
+
}
|
|
150
|
+
if (['app_store', 'google_play'].includes(platform)) throw new Error('credential_file_required')
|
|
151
|
+
if (options.inspectOnly) return { requiresSelection: false, choices: [] }
|
|
152
|
+
const names =
|
|
153
|
+
platform === 'samsung'
|
|
154
|
+
? { serviceAccountId: ['serviceaccountid', '服务账号id', '服务帐户id'] }
|
|
155
|
+
: platform === 'xiaomi'
|
|
156
|
+
? {
|
|
157
|
+
userName: ['username', '开发者邮箱'],
|
|
158
|
+
privateKey: ['privatekey', 'api访问密码', '私钥'],
|
|
159
|
+
}
|
|
160
|
+
: platform === 'vivo'
|
|
161
|
+
? { accessKey: ['accesskey'], accessSecret: ['accesssecret'] }
|
|
162
|
+
: platform === 'tencent'
|
|
163
|
+
? { userId: ['userid'], appId: ['appid'], accessSecret: ['accesssecret'] }
|
|
164
|
+
: platform !== 'pgyer'
|
|
165
|
+
? { clientId: ['clientid', '客户端id'], clientSecret: ['clientsecret', '客户端密钥'] }
|
|
166
|
+
: { apiKey: ['apikey', 'api密钥'] }
|
|
167
|
+
const fields = {}
|
|
168
|
+
for (const [field, aliases] of Object.entries(names)) {
|
|
169
|
+
const values = new Set()
|
|
170
|
+
for (const node of document.querySelectorAll('input,textarea,[data-field]')) {
|
|
171
|
+
if (!visible(node)) continue
|
|
172
|
+
const labels = [
|
|
173
|
+
node.id,
|
|
174
|
+
node.getAttribute('name'),
|
|
175
|
+
node.getAttribute('data-field'),
|
|
176
|
+
node.getAttribute('aria-label'),
|
|
177
|
+
...Array.from(node.labels ?? []).map((label) => label.textContent),
|
|
178
|
+
]
|
|
179
|
+
if (labels.some((label) => aliases.includes(normalize(label))))
|
|
180
|
+
values.add(String(node.value ?? node.textContent).trim())
|
|
181
|
+
}
|
|
182
|
+
for (const label of document.querySelectorAll('label,td,dt')) {
|
|
183
|
+
if (!visible(label) || !aliases.includes(normalize(label.textContent))) continue
|
|
184
|
+
const region = label.closest('tr,.form-group,.el-form-item,.ant-form-item')
|
|
185
|
+
const value = region?.querySelector('input,textarea,code') ?? label.nextElementSibling
|
|
186
|
+
if (visible(value)) values.add(String(value.value ?? value.textContent).trim())
|
|
187
|
+
}
|
|
188
|
+
const candidates = [...values].filter(
|
|
189
|
+
(value) => value && value.length <= 8192 && !/[\r\n\0*•●]/u.test(value),
|
|
190
|
+
)
|
|
191
|
+
if (candidates.length !== 1) throw new Error('credentials_not_visible')
|
|
192
|
+
fields[field] = candidates[0]
|
|
193
|
+
}
|
|
194
|
+
return fields
|
|
195
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Flu 发布凭据助手",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "为已发起的本机 Flu 配置任务采集商店页面中的已有凭据。",
|
|
6
|
+
"permissions": ["activeTab", "scripting", "nativeMessaging"],
|
|
7
|
+
"action": { "default_title": "为当前 Flu 任务配置已有凭据", "default_popup": "popup.html" }
|
|
8
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta
|
|
5
|
+
name="viewport"
|
|
6
|
+
content="width=device-width, initial-scale=1"
|
|
7
|
+
/>
|
|
8
|
+
<title>Flu 发布凭据助手</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light dark;
|
|
12
|
+
font: 14px/1.5 system-ui;
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
min-width: 300px;
|
|
16
|
+
max-width: 360px;
|
|
17
|
+
padding: 16px;
|
|
18
|
+
margin: 0;
|
|
19
|
+
}
|
|
20
|
+
h1 {
|
|
21
|
+
font-size: 16px;
|
|
22
|
+
margin: 0 0 12px;
|
|
23
|
+
}
|
|
24
|
+
button,
|
|
25
|
+
select,
|
|
26
|
+
input {
|
|
27
|
+
font: inherit;
|
|
28
|
+
max-width: 100%;
|
|
29
|
+
}
|
|
30
|
+
button {
|
|
31
|
+
padding: 7px 10px;
|
|
32
|
+
}
|
|
33
|
+
label,
|
|
34
|
+
select {
|
|
35
|
+
display: block;
|
|
36
|
+
margin: 8px 0;
|
|
37
|
+
}
|
|
38
|
+
select {
|
|
39
|
+
width: 100%;
|
|
40
|
+
}
|
|
41
|
+
[hidden] {
|
|
42
|
+
display: none;
|
|
43
|
+
}
|
|
44
|
+
p {
|
|
45
|
+
margin: 12px 0 0;
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
48
|
+
<h1>Flu 发布凭据助手</h1>
|
|
49
|
+
<label for="task">当前 Flu 配置任务</label>
|
|
50
|
+
<select
|
|
51
|
+
id="task"
|
|
52
|
+
disabled
|
|
53
|
+
>
|
|
54
|
+
<option>正在读取本机任务…</option>
|
|
55
|
+
</select>
|
|
56
|
+
<button
|
|
57
|
+
id="read"
|
|
58
|
+
disabled
|
|
59
|
+
>
|
|
60
|
+
读取当前账号页的已有凭据
|
|
61
|
+
</button>
|
|
62
|
+
<label
|
|
63
|
+
id="credential-label"
|
|
64
|
+
for="credential"
|
|
65
|
+
hidden
|
|
66
|
+
>开发者级 API 客户端</label
|
|
67
|
+
>
|
|
68
|
+
<select
|
|
69
|
+
id="credential"
|
|
70
|
+
hidden
|
|
71
|
+
></select>
|
|
72
|
+
<label
|
|
73
|
+
id="account-label"
|
|
74
|
+
for="account"
|
|
75
|
+
>文件配对账号:三星 Service Account ID / 小米开发者邮箱 / Apple Issuer ID</label
|
|
76
|
+
>
|
|
77
|
+
<input
|
|
78
|
+
id="account"
|
|
79
|
+
type="text"
|
|
80
|
+
autocomplete="off"
|
|
81
|
+
disabled
|
|
82
|
+
/>
|
|
83
|
+
<label
|
|
84
|
+
id="file-secret-label"
|
|
85
|
+
for="file-secret"
|
|
86
|
+
>小米 API 访问密码(不是登录密码)</label
|
|
87
|
+
>
|
|
88
|
+
<input
|
|
89
|
+
id="file-secret"
|
|
90
|
+
type="password"
|
|
91
|
+
autocomplete="off"
|
|
92
|
+
disabled
|
|
93
|
+
/>
|
|
94
|
+
<label
|
|
95
|
+
id="key-id-label"
|
|
96
|
+
for="key-id"
|
|
97
|
+
>Apple 团队 API Key ID</label
|
|
98
|
+
>
|
|
99
|
+
<input
|
|
100
|
+
id="key-id"
|
|
101
|
+
type="text"
|
|
102
|
+
autocomplete="off"
|
|
103
|
+
disabled
|
|
104
|
+
/>
|
|
105
|
+
<label
|
|
106
|
+
id="apple-id-label"
|
|
107
|
+
for="apple-id"
|
|
108
|
+
>Apple ID(当前 CLI 上传所需)</label
|
|
109
|
+
>
|
|
110
|
+
<input
|
|
111
|
+
id="apple-id"
|
|
112
|
+
type="text"
|
|
113
|
+
autocomplete="off"
|
|
114
|
+
disabled
|
|
115
|
+
/>
|
|
116
|
+
<label
|
|
117
|
+
id="app-password-label"
|
|
118
|
+
for="app-password"
|
|
119
|
+
>已有 App 专用密码(不能使用登录密码)</label
|
|
120
|
+
>
|
|
121
|
+
<input
|
|
122
|
+
id="app-password"
|
|
123
|
+
type="password"
|
|
124
|
+
autocomplete="off"
|
|
125
|
+
disabled
|
|
126
|
+
/>
|
|
127
|
+
<p id="file-help">
|
|
128
|
+
私钥只能下载一次,助手不能恢复丢失文件。请导入已经下载的文件,不要创建或重置密钥。
|
|
129
|
+
</p>
|
|
130
|
+
<label
|
|
131
|
+
id="file-label"
|
|
132
|
+
for="file"
|
|
133
|
+
>导入已有文件:华为/鸿蒙/Google JSON;三星 PEM;Apple P8;小米公钥/证书</label
|
|
134
|
+
>
|
|
135
|
+
<input
|
|
136
|
+
id="file"
|
|
137
|
+
type="file"
|
|
138
|
+
accept=".json,.pem,.key,.p8,.cer,application/json,application/x-pem-file"
|
|
139
|
+
disabled
|
|
140
|
+
/>
|
|
141
|
+
<p
|
|
142
|
+
id="status"
|
|
143
|
+
role="status"
|
|
144
|
+
aria-live="polite"
|
|
145
|
+
>
|
|
146
|
+
凭据仅保存到本机,不进入 AI 对话。
|
|
147
|
+
</p>
|
|
148
|
+
<script
|
|
149
|
+
src="popup.js"
|
|
150
|
+
type="module"
|
|
151
|
+
></script>
|
|
152
|
+
</html>
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// 本文件在扩展自身页面采集或导入已有凭据;只把脱敏状态显示给用户。
|
|
2
|
+
import { visibleFields } from './capture-fields.js'
|
|
3
|
+
|
|
4
|
+
const HOST = 'cn.huoye.app_ship.credentials'
|
|
5
|
+
const select = document.getElementById('task')
|
|
6
|
+
const read = document.getElementById('read')
|
|
7
|
+
const file = document.getElementById('file')
|
|
8
|
+
const status = document.getElementById('status')
|
|
9
|
+
const account = document.getElementById('account')
|
|
10
|
+
const fileSecret = document.getElementById('file-secret')
|
|
11
|
+
const keyId = document.getElementById('key-id')
|
|
12
|
+
const appleId = document.getElementById('apple-id')
|
|
13
|
+
const appPassword = document.getElementById('app-password')
|
|
14
|
+
const fileHelp = document.getElementById('file-help')
|
|
15
|
+
const credential = document.getElementById('credential')
|
|
16
|
+
const credentialLabel = document.getElementById('credential-label')
|
|
17
|
+
const tasks = new Map()
|
|
18
|
+
let tab
|
|
19
|
+
let busy = false
|
|
20
|
+
let choices = []
|
|
21
|
+
let requiresSelection = false
|
|
22
|
+
let pageReady = false
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 向已注册的本机连接发送当前用户动作,返回脱敏状态。
|
|
26
|
+
* @param message - 扩展自身构造的任务消息;秘密只经本机管道传输。
|
|
27
|
+
*/
|
|
28
|
+
async function native(message) {
|
|
29
|
+
const value = await chrome.runtime.sendNativeMessage(HOST, message)
|
|
30
|
+
if (!value?.ok) throw new Error(value?.error || 'connector_unavailable')
|
|
31
|
+
return value.result
|
|
32
|
+
}
|
|
33
|
+
/** 按当前任务与忙碌状态启用控件,不读取或保存凭据。 */
|
|
34
|
+
function update() {
|
|
35
|
+
const task = tasks.get(select.value)
|
|
36
|
+
select.disabled = busy || !tasks.size
|
|
37
|
+
read.disabled =
|
|
38
|
+
busy || !task || !task.fromPage || !pageReady || (requiresSelection && !credential.value)
|
|
39
|
+
credential.disabled = busy
|
|
40
|
+
const filePlatform = [
|
|
41
|
+
'huawei',
|
|
42
|
+
'harmony',
|
|
43
|
+
'samsung',
|
|
44
|
+
'xiaomi',
|
|
45
|
+
'app_store',
|
|
46
|
+
'google_play',
|
|
47
|
+
].includes(task?.platform)
|
|
48
|
+
for (const [id, shown] of [
|
|
49
|
+
['account', ['samsung', 'xiaomi', 'app_store'].includes(task?.platform)],
|
|
50
|
+
['file-secret', task?.platform === 'xiaomi'],
|
|
51
|
+
['key-id', task?.platform === 'app_store'],
|
|
52
|
+
['apple-id', task?.platform === 'app_store'],
|
|
53
|
+
['app-password', task?.platform === 'app_store'],
|
|
54
|
+
['file', filePlatform],
|
|
55
|
+
]) {
|
|
56
|
+
document.getElementById(id).hidden = document.getElementById(id + '-label').hidden = !shown
|
|
57
|
+
}
|
|
58
|
+
fileHelp.hidden = !filePlatform
|
|
59
|
+
document.getElementById('account-label').textContent =
|
|
60
|
+
task?.platform === 'xiaomi'
|
|
61
|
+
? '小米开发者邮箱'
|
|
62
|
+
: task?.platform === 'app_store'
|
|
63
|
+
? 'Apple 团队 Issuer ID'
|
|
64
|
+
: '三星 Service Account ID(可从当前页读取)'
|
|
65
|
+
file.disabled =
|
|
66
|
+
busy ||
|
|
67
|
+
!task ||
|
|
68
|
+
!['huawei', 'harmony', 'samsung', 'xiaomi', 'app_store', 'google_play'].includes(task.platform)
|
|
69
|
+
account.disabled = busy || !['samsung', 'xiaomi', 'app_store'].includes(task?.platform)
|
|
70
|
+
fileSecret.disabled = busy || task?.platform !== 'xiaomi'
|
|
71
|
+
keyId.disabled = busy || task?.platform !== 'app_store'
|
|
72
|
+
appleId.disabled = appPassword.disabled = busy || task?.platform !== 'app_store'
|
|
73
|
+
fileHelp.textContent =
|
|
74
|
+
task?.platform === 'xiaomi'
|
|
75
|
+
? '填写开发者邮箱与已有 API 访问密码,再导入平台公钥或证书;不要填写小米账号登录密码。'
|
|
76
|
+
: task?.platform === 'app_store'
|
|
77
|
+
? '填写团队 Key 的 Issuer ID / Key ID 并导入 .p8;当前 CLI 上传还需已有 Apple ID 与 App 专用密码。私钥不能从网页恢复;只读核验只证明 API Key 与应用匹配。'
|
|
78
|
+
: task?.platform === 'google_play'
|
|
79
|
+
? '导入已有服务账号 JSON;私钥仅在创建时可下载,不能从 Cloud 或 Play 页面恢复。只读查询需要目标应用查看权限。'
|
|
80
|
+
: '导入当前账号已经下载的文件;助手不会创建、重置密钥或扩大权限。'
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 将已知采集错误转为可操作的提示,不显示浏览器原文、认证字段或异常堆栈。
|
|
84
|
+
* @param error - 本机连接或页面采集抛出的错误,仅匹配固定错误代码。
|
|
85
|
+
* @param platform - 当前任务的商店标识,用于说明对应凭据页的处理方式。
|
|
86
|
+
*/
|
|
87
|
+
function captureMessage(error, platform) {
|
|
88
|
+
const code = String(error?.message || '')
|
|
89
|
+
if (code.includes('invalid_import_fields'))
|
|
90
|
+
return '请补齐当前文件的配对账号字段。Apple 还需已有 Apple ID 与 App 专用密码;小米需邮箱与 API 访问密码。'
|
|
91
|
+
if (code.includes('invalid_apple_key'))
|
|
92
|
+
return '请导入已有团队 API Key 的 P-256 .p8,并核对对应的 Issuer ID 与 Key ID;不支持个人 API Key。'
|
|
93
|
+
if (code.includes('invalid_service_account'))
|
|
94
|
+
return '请导入已有 Google 服务账号 JSON(含 RSA 私钥),不支持 OAuth 客户端或自定义 token 地址。'
|
|
95
|
+
if (code.includes('invalid_public_key'))
|
|
96
|
+
return '请导入小米平台分配的已有 RSA 公钥或证书,不要导入 RSA 私钥。'
|
|
97
|
+
if (code.includes('invalid_credential_file'))
|
|
98
|
+
return '文件类型或大小不符合当前平台要求,请选择已经下载且不超过 64 KB 的配对文件。'
|
|
99
|
+
if (code.includes('credential_setup_not_pending'))
|
|
100
|
+
return '任务已完成、取消或过期;请回到 Flu 核对状态,必要时重新发起采集。'
|
|
101
|
+
if (code.includes('pgyer_api_key_required'))
|
|
102
|
+
return '请在蒲公英账号的“API 信息”页确认已有 API Key 卡片可见,再重新打开助手。'
|
|
103
|
+
if (code.includes('huawei_developer_credentials_required'))
|
|
104
|
+
return '请在华为“凭证”页面选择“开发者级凭证”,再重新打开助手。'
|
|
105
|
+
if (code.includes('huawei_api_client_missing'))
|
|
106
|
+
return '当前开发者级 API 客户端列表没有可见记录;可导入已有服务账号 JSON。'
|
|
107
|
+
if (code.includes('credentials_not_visible'))
|
|
108
|
+
return platform === 'pgyer'
|
|
109
|
+
? '蒲公英 API Key 被遮盖或无法完整读取,请核对 API 信息页后重新打开助手。'
|
|
110
|
+
: '当前账号页的凭据被遮盖、标签不明确或存在多个候选。请核对当前平台 API 凭据页;文件凭据从助手导入。'
|
|
111
|
+
if (code.includes('credential_selection_changed'))
|
|
112
|
+
return '所选凭证行已变化,请重新打开助手并选择对应 API 客户端。'
|
|
113
|
+
if (code.includes('credential_file_required'))
|
|
114
|
+
return '此平台使用已下载的文件凭据,请在助手下方导入;无法恢复丢失的私钥。'
|
|
115
|
+
if (code.includes('page_changed')) return '商店页面已变化,请回到凭据页后重新打开助手。'
|
|
116
|
+
return '未完成。请核对任务期限、本机连接及已有凭据;不会创建或重置商店密钥。'
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 在用户当前标签页执行可见页面读取;页面变化时停止,不访问网页存储或网络。
|
|
120
|
+
* @param task - 当前本机任务的公开平台与关联信息。
|
|
121
|
+
* @param options - 仅检查公开选择项,或携带用户选中的公开行标识。
|
|
122
|
+
*/
|
|
123
|
+
async function readPage(task, options) {
|
|
124
|
+
const current = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]
|
|
125
|
+
if (!tab?.id || current?.id !== tab.id || current.url !== tab.url) throw new Error('page_changed')
|
|
126
|
+
const result = await chrome.scripting.executeScript({
|
|
127
|
+
target: { tabId: tab.id },
|
|
128
|
+
func: visibleFields,
|
|
129
|
+
args: [task.platform, options],
|
|
130
|
+
})
|
|
131
|
+
return result[0]?.result
|
|
132
|
+
}
|
|
133
|
+
/** 检查当前任务可见的凭据结构及公开选择项;检查阶段不读取 ID 或密钥。 */
|
|
134
|
+
async function refreshPageChoices() {
|
|
135
|
+
const task = tasks.get(select.value)
|
|
136
|
+
pageReady = false
|
|
137
|
+
choices = []
|
|
138
|
+
requiresSelection = false
|
|
139
|
+
credential.hidden = credentialLabel.hidden = true
|
|
140
|
+
if (!task?.fromPage) {
|
|
141
|
+
status.textContent = '请按下方平台说明导入当前任务已有的文件凭据。'
|
|
142
|
+
update()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
busy = true
|
|
146
|
+
update()
|
|
147
|
+
try {
|
|
148
|
+
const result = await readPage(task, { inspectOnly: true })
|
|
149
|
+
requiresSelection = result.requiresSelection
|
|
150
|
+
choices = result.choices
|
|
151
|
+
credential.replaceChildren(
|
|
152
|
+
...[
|
|
153
|
+
...(choices.length > 1 ? [{ key: '', label: '请选择本次使用的 API 客户端' }] : []),
|
|
154
|
+
...choices,
|
|
155
|
+
].map((choice) => {
|
|
156
|
+
const option = document.createElement('option')
|
|
157
|
+
option.value = choice.key
|
|
158
|
+
option.textContent = choice.label
|
|
159
|
+
return option
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
credential.hidden = credentialLabel.hidden = !requiresSelection
|
|
163
|
+
pageReady = true
|
|
164
|
+
status.textContent = requiresSelection
|
|
165
|
+
? '已识别开发者级 API 客户端。读取后由 Flu 只读核验应用匹配;不会上传安装包。'
|
|
166
|
+
: task.platform === 'pgyer'
|
|
167
|
+
? '已识别蒲公英 API 信息页。点击读取已有 API Key 后由 Flu 只读鉴权;不会上传安装包。'
|
|
168
|
+
: '选择任务后读取已有凭据,或导入已下载的服务账号 JSON。'
|
|
169
|
+
} catch (error) {
|
|
170
|
+
status.textContent = captureMessage(error, task.platform)
|
|
171
|
+
} finally {
|
|
172
|
+
busy = false
|
|
173
|
+
update()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 执行用户点选的采集或文件导入,结束后清空文件输入并显示脱敏结果。
|
|
178
|
+
* @param operation - 接收当前已选任务的异步采集回调,必须返回明确的采集状态。
|
|
179
|
+
*/
|
|
180
|
+
async function execute(operation) {
|
|
181
|
+
if (busy || !tasks.has(select.value)) return
|
|
182
|
+
busy = true
|
|
183
|
+
update()
|
|
184
|
+
status.textContent = '正在保存到本机安全库…'
|
|
185
|
+
try {
|
|
186
|
+
const result = await operation(tasks.get(select.value))
|
|
187
|
+
if (result.status === 'account_read') {
|
|
188
|
+
account.value = result.serviceAccountId || result.userName
|
|
189
|
+
fileSecret.value = result.privateKey || ''
|
|
190
|
+
status.textContent = result.userName
|
|
191
|
+
? '已读取小米 API 账号,请选择平台分配的公钥或证书。'
|
|
192
|
+
: '已读取账号 ID,请选择该账号已下载的私钥文件。'
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (result.status !== 'captured') throw new Error('capture_not_completed')
|
|
196
|
+
tasks.delete(result.setupId)
|
|
197
|
+
select.querySelector(`option[value="${result.setupId}"]`)?.remove()
|
|
198
|
+
status.textContent =
|
|
199
|
+
'已有凭据已保存到本机。Flu 插件会自动跟进并核验;从 CLI 发起的任务请执行 complete。'
|
|
200
|
+
account.value = fileSecret.value = keyId.value = appleId.value = appPassword.value = ''
|
|
201
|
+
} catch (error) {
|
|
202
|
+
status.textContent = captureMessage(error, tasks.get(select.value)?.platform)
|
|
203
|
+
} finally {
|
|
204
|
+
busy = false
|
|
205
|
+
file.value = ''
|
|
206
|
+
update()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
read.addEventListener('click', () =>
|
|
211
|
+
execute(async (task) => {
|
|
212
|
+
const choice = choices.find((entry) => entry.key === credential.value)
|
|
213
|
+
const fields = await readPage(
|
|
214
|
+
task,
|
|
215
|
+
choice ? { choiceKey: choice.key, choiceLabel: choice.label } : {},
|
|
216
|
+
)
|
|
217
|
+
try {
|
|
218
|
+
if (task.platform === 'xiaomi' && fields?.userName)
|
|
219
|
+
return { status: 'account_read', userName: fields.userName, privateKey: fields.privateKey }
|
|
220
|
+
if (task.platform === 'samsung' && fields?.serviceAccountId)
|
|
221
|
+
return { status: 'account_read', serviceAccountId: fields.serviceAccountId }
|
|
222
|
+
return await native({ operation: 'capture', setupId: task.setupId, pageUrl: tab.url, fields })
|
|
223
|
+
} finally {
|
|
224
|
+
if (fields) for (const key of Object.keys(fields)) fields[key] = ''
|
|
225
|
+
}
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
file.addEventListener('change', () =>
|
|
229
|
+
execute(async (task) => {
|
|
230
|
+
const selected = file.files?.[0]
|
|
231
|
+
if (
|
|
232
|
+
!selected ||
|
|
233
|
+
selected.size > 65_000 ||
|
|
234
|
+
!(
|
|
235
|
+
task.platform === 'samsung'
|
|
236
|
+
? /\.(?:pem|key)$/iu
|
|
237
|
+
: task.platform === 'app_store'
|
|
238
|
+
? /\.p8$/iu
|
|
239
|
+
: task.platform === 'xiaomi'
|
|
240
|
+
? /\.(?:pem|cer|key)$/iu
|
|
241
|
+
: /\.json$/iu
|
|
242
|
+
).test(selected.name)
|
|
243
|
+
)
|
|
244
|
+
throw new Error('invalid_credential_file')
|
|
245
|
+
let content = await selected.text()
|
|
246
|
+
if (task.platform === 'xiaomi' && !content.includes('-----BEGIN')) {
|
|
247
|
+
const bytes = new Uint8Array(await selected.arrayBuffer())
|
|
248
|
+
content = `-----BEGIN CERTIFICATE-----\n${btoa(String.fromCharCode(...bytes))}\n-----END CERTIFICATE-----`
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
return await native({
|
|
252
|
+
operation: 'import_file',
|
|
253
|
+
setupId: task.setupId,
|
|
254
|
+
content,
|
|
255
|
+
...(task.platform === 'samsung'
|
|
256
|
+
? { serviceAccountId: account.value.trim() }
|
|
257
|
+
: ['xiaomi', 'app_store', 'google_play'].includes(task.platform)
|
|
258
|
+
? {
|
|
259
|
+
fields:
|
|
260
|
+
task.platform === 'xiaomi'
|
|
261
|
+
? { userName: account.value.trim(), privateKey: fileSecret.value }
|
|
262
|
+
: task.platform === 'app_store'
|
|
263
|
+
? {
|
|
264
|
+
apiIssuerId: account.value.trim(),
|
|
265
|
+
apiKeyId: keyId.value.trim(),
|
|
266
|
+
appleId: appleId.value.trim(),
|
|
267
|
+
appPassword: appPassword.value,
|
|
268
|
+
}
|
|
269
|
+
: {},
|
|
270
|
+
}
|
|
271
|
+
: {}),
|
|
272
|
+
})
|
|
273
|
+
} finally {
|
|
274
|
+
content = ''
|
|
275
|
+
}
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
select.addEventListener('change', () => {
|
|
279
|
+
account.value = fileSecret.value = keyId.value = appleId.value = appPassword.value = ''
|
|
280
|
+
refreshPageChoices()
|
|
281
|
+
})
|
|
282
|
+
credential.addEventListener('change', update)
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]
|
|
286
|
+
const imported = await native({ operation: 'pending_imports' })
|
|
287
|
+
for (const task of imported.requests) tasks.set(task.setupId, task)
|
|
288
|
+
if (tab?.url) {
|
|
289
|
+
// 当前网页不受支持时仍允许用户从扩展内导入本地 JSON,不把文件交给网页。
|
|
290
|
+
try {
|
|
291
|
+
const pending = await native({ operation: 'pending', pageUrl: tab.url })
|
|
292
|
+
for (const task of pending.requests) tasks.set(task.setupId, { ...task, fromPage: true })
|
|
293
|
+
} catch {
|
|
294
|
+
/* 网页采集范围由本机边界决定。 */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
select.replaceChildren(
|
|
298
|
+
...[...tasks.values()].map((task) => {
|
|
299
|
+
const option = document.createElement('option')
|
|
300
|
+
option.value = task.setupId
|
|
301
|
+
option.textContent = `${task.platform} · ${task.packageName} · ${task.setupId.slice(0, 8)}`
|
|
302
|
+
return option
|
|
303
|
+
}),
|
|
304
|
+
)
|
|
305
|
+
status.textContent = tasks.size
|
|
306
|
+
? '选择任务后读取已有凭据,或导入已下载的服务账号 JSON。'
|
|
307
|
+
: '没有待采集任务。请先从 Flu 为明确项目开始凭据配置。'
|
|
308
|
+
if (tasks.size) await refreshPageChoices()
|
|
309
|
+
} catch {
|
|
310
|
+
status.textContent = '本机连接不可用。请在 IDE 的“准备浏览器助手”中选择“连接到 Flu”。'
|
|
311
|
+
}
|
|
312
|
+
update()
|