@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.
@@ -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()