dsh-plugin-cicd 0.0.0-stage → 0.5.1
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/LICENSE +21 -0
- package/README.md +402 -2
- package/RELEASING.md +63 -0
- package/client.js +4417 -0
- package/cordis.patch.yml +63 -0
- package/icon.svg +8 -0
- package/index.js +4938 -0
- package/lib/bilibili.mjs +1000 -0
- package/lib/config-store.mjs +159 -0
- package/package.json +62 -3
- package/scripts/configure.mjs +213 -0
- package/scripts/push-via-api.ps1 +111 -0
- package/scripts/verify-bundle.mjs +320 -0
- package/tests/bilibili-check.mjs +450 -0
- package/tests/bump-e2e.mjs +431 -0
- package/tests/client-render.mjs +1483 -0
- package/tests/host-checks.mjs +576 -0
- package/tests/mount-check.mjs +300 -0
package/client.js
ADDED
|
@@ -0,0 +1,4417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the `dsh-plugin-cicd` bundle — 发布台 (Release Console).
|
|
3
|
+
*
|
|
4
|
+
* Four registrations:
|
|
5
|
+
* main the operating view: one compact row per repository
|
|
6
|
+
* sidebar.panellist its sidebar button
|
|
7
|
+
* settings.plugins.tab 「DSH 插件发布台」: the three credentials, one page
|
|
8
|
+
*
|
|
9
|
+
* That settings page holds GitHub, npm and Bilibili as SEGMENTS rather than as three
|
|
10
|
+
* pages of their own. Both shared surfaces this plugin could have taken are flat
|
|
11
|
+
* lists — `settings.section` (the top-level nav) and `settings.plugins.tab` (the strip
|
|
12
|
+
* inside Plugins) — and one row per plugin is this plugin's share of each.
|
|
13
|
+
*
|
|
14
|
+
* The layout rule is density. This panel's job is to answer "which of my
|
|
15
|
+
* repositories needs attention" at a glance, and a card per repository with a
|
|
16
|
+
* path line, a run line and a release list answers it in three screens instead of
|
|
17
|
+
* one. So a row is ~32px and carries only what is actionable; everything else
|
|
18
|
+
* (path, releases, run history, logs) lives in the row's expansion.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here requires a terminal. Signing in runs `gh auth login --web` from the
|
|
21
|
+
* Host and renders the one-time code; registering a repository is a tick on a list
|
|
22
|
+
* fetched from GitHub. The commands still exist — `configure.mjs` is the scripted
|
|
23
|
+
* path, and the setup block shows them collapsed — but they are the fallback, not
|
|
24
|
+
* the instruction.
|
|
25
|
+
*
|
|
26
|
+
* Deliberate choices:
|
|
27
|
+
* - No Harness Client package is required; only `react`, which is a platform
|
|
28
|
+
* seed word in the client module table.
|
|
29
|
+
* - The stylesheet is tagged `data-plugin`/`data-plugin-css` so the loader's
|
|
30
|
+
* claimStyles/removeOwnedStyles cannot take it over or delete it.
|
|
31
|
+
* - A failed BACKGROUND poll never clears the last known state and never raises
|
|
32
|
+
* a banner: it stops updating, so a sleeping machine produces no error the
|
|
33
|
+
* user did not cause.
|
|
34
|
+
* - Colours come only from `--dsw-alias-*`, so both themes stay legible.
|
|
35
|
+
* - Publishing a draft is public and one-way, so it takes two clicks.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
window.__ModuleLoader__.load({
|
|
39
|
+
id: 'dsh-plugin-cicd',
|
|
40
|
+
factory(require) {
|
|
41
|
+
const React = require('react')
|
|
42
|
+
const h = React.createElement
|
|
43
|
+
|
|
44
|
+
const NS = 'dsh-cicd'
|
|
45
|
+
/** Sidebar entry id and `main` slot key — the same value links the two. */
|
|
46
|
+
const PANEL_ID = 'dsh-cicd'
|
|
47
|
+
/**
|
|
48
|
+
* The console's one page in the Plugins section. `account`, `general` and the rest
|
|
49
|
+
* are shipped sections, so this must not reuse one of their ids.
|
|
50
|
+
*/
|
|
51
|
+
const SETTINGS_ID = 'dsh-cicd'
|
|
52
|
+
/**
|
|
53
|
+
* The three credentials, as segments inside that one page.
|
|
54
|
+
*
|
|
55
|
+
* They are not tabs of their own: this plugin would then take three of the rows in
|
|
56
|
+
* a tab strip shared with every other plugin, which is the same unfair share of a
|
|
57
|
+
* shared surface that moved them out of the top-level nav in the first place. As
|
|
58
|
+
* segments they stay one thing — the console's settings — and the strip stays one
|
|
59
|
+
* row per plugin.
|
|
60
|
+
*/
|
|
61
|
+
const SEGMENT_GITHUB = 'github'
|
|
62
|
+
const SEGMENT_NPM = 'npm'
|
|
63
|
+
const SEGMENT_BILIBILI = 'bilibili'
|
|
64
|
+
const STYLE_ID = 'dsh-plugin-cicd-styles'
|
|
65
|
+
|
|
66
|
+
const BASE = '/api/dsh-cicd'
|
|
67
|
+
/**
|
|
68
|
+
* The pages a first npm credential needs.
|
|
69
|
+
*
|
|
70
|
+
* Two of these come from GitHub's own migration note for the November 2025 npm
|
|
71
|
+
* token change, which is also where the fact that classic tokens are gone was
|
|
72
|
+
* confirmed — a guide that guessed at either would send a first-time publisher
|
|
73
|
+
* somewhere that no longer exists.
|
|
74
|
+
*/
|
|
75
|
+
const NPM_SIGNUP_URL = 'https://www.npmjs.com/signup'
|
|
76
|
+
const NPM_TOKENS_URL = 'https://www.npmjs.com/settings/~/tokens'
|
|
77
|
+
const NPM_TOKEN_DOCS_URL = 'https://docs.npmjs.com/creating-and-viewing-access-tokens'
|
|
78
|
+
/**
|
|
79
|
+
* The Host protocol this page needs.
|
|
80
|
+
*
|
|
81
|
+
* A page refresh replaces this file but not the Host process, so a route can
|
|
82
|
+
* be missing while the button that calls it is on screen. Comparing this
|
|
83
|
+
* number turns that into one sentence instead of a bare HTTP status.
|
|
84
|
+
*/
|
|
85
|
+
const PROTOCOL = 6
|
|
86
|
+
const STATUS_TIMEOUT_MS = 20_000
|
|
87
|
+
const OVERVIEW_TIMEOUT_MS = 60_000
|
|
88
|
+
const ACTION_TIMEOUT_MS = 45_000
|
|
89
|
+
/**
|
|
90
|
+
* An update downloads a release tarball and then runs pnpm against the profile.
|
|
91
|
+
* Both are seconds of real work on a machine that is doing nothing else, and the
|
|
92
|
+
* first pnpm run after a store change is the slow one — so this action gets its
|
|
93
|
+
* own, longer bound instead of borrowing the 45 s the dispatch actions use.
|
|
94
|
+
*/
|
|
95
|
+
const UPDATE_TIMEOUT_MS = 180_000
|
|
96
|
+
/**
|
|
97
|
+
* Asking npm is one HTTPS request per repository plus a `whoami`; a publish
|
|
98
|
+
* uploads the tarball. Neither is an "action" a person waits on twice, so the
|
|
99
|
+
* status gets a short bound and the publish shares the update's long one.
|
|
100
|
+
*/
|
|
101
|
+
const NPM_TIMEOUT_MS = 45_000
|
|
102
|
+
const NOTICE_TTL_MS = 5_000
|
|
103
|
+
/**
|
|
104
|
+
* While an attempt is pending this polls `/auth-state` — and it is the only
|
|
105
|
+
* thing the user is waiting for at that moment. One second rather than two: the
|
|
106
|
+
* code normally exists within a second of the click, this is a loopback call,
|
|
107
|
+
* and it stops as soon as the attempt settles.
|
|
108
|
+
*/
|
|
109
|
+
const AUTH_POLL_MS = 1_000
|
|
110
|
+
|
|
111
|
+
/** Simplified Chinese dictionary (key-set source of truth). */
|
|
112
|
+
const zh = {
|
|
113
|
+
'panel': 'DSH 插件发布台',
|
|
114
|
+
'title': 'DSH 插件发布台',
|
|
115
|
+
'subtitle': '持续构建与发布(CI/CD)',
|
|
116
|
+
'settings.consoleLabel': 'DSH 插件发布台',
|
|
117
|
+
'settings.segment.github': 'GitHub',
|
|
118
|
+
'settings.title': 'GitHub 账户',
|
|
119
|
+
'settings.subtitle': '发布台用这台机器上的 gh 访问 GitHub,插件自己不保存任何凭据。',
|
|
120
|
+
'action.refresh': '刷新',
|
|
121
|
+
'action.manage': '管理仓库',
|
|
122
|
+
'action.close': '收起',
|
|
123
|
+
'action.commit': '提交',
|
|
124
|
+
'action.push': '推送',
|
|
125
|
+
'action.commitConfirm': '确认提交',
|
|
126
|
+
'action.pushConfirm': '确认推送',
|
|
127
|
+
'action.build': '构建',
|
|
128
|
+
'action.release': '发布',
|
|
129
|
+
'action.bumpRelease': '升版本并发布',
|
|
130
|
+
'action.publishDraft': '公开草稿',
|
|
131
|
+
'action.confirmPublish': '确认公开?',
|
|
132
|
+
'action.update': '更新到 {tag}',
|
|
133
|
+
'action.installRelease': '装 Release {tag}',
|
|
134
|
+
'action.confirmUpdate': '确认更新',
|
|
135
|
+
'action.restartNow': '立即重启 DSH',
|
|
136
|
+
'action.restartLater': '稍后',
|
|
137
|
+
'action.open': '打开',
|
|
138
|
+
'action.logs': '日志',
|
|
139
|
+
'action.hideLogs': '收起日志',
|
|
140
|
+
'action.rerun': '重跑',
|
|
141
|
+
'action.cancel': '取消',
|
|
142
|
+
'action.expand': '详情',
|
|
143
|
+
'action.collapse': '收起',
|
|
144
|
+
'action.add': '添加',
|
|
145
|
+
'action.remove': '移除',
|
|
146
|
+
'action.signIn': '用浏览器登录 GitHub',
|
|
147
|
+
'action.recheck': '重新检测',
|
|
148
|
+
'action.grantScopes': '补齐权限',
|
|
149
|
+
'action.copy': '复制',
|
|
150
|
+
'action.copied': '已复制',
|
|
151
|
+
'action.openDevicePage': '打开授权页面',
|
|
152
|
+
'action.cancelSignIn': '取消登录',
|
|
153
|
+
'action.signOut': '退出登录',
|
|
154
|
+
'action.signOutConfirm': '确认退出登录?',
|
|
155
|
+
'state.signedOut': '已退出登录:令牌已从系统凭据存储移除。注意 git 推送会随之失效(git 的凭据本来就由 gh 提供),重新登录后恢复。',
|
|
156
|
+
'label.runs': '运行记录',
|
|
157
|
+
'chip.published': '已发布',
|
|
158
|
+
'chip.unpublished': '未发布',
|
|
159
|
+
'chip.draft': '草稿',
|
|
160
|
+
'chip.versionTaken': '版本被占用',
|
|
161
|
+
'chip.update': '可更新 {tag}',
|
|
162
|
+
'chip.checkout': '本地检出',
|
|
163
|
+
'chip.dirty': '未提交 {count}',
|
|
164
|
+
'chip.ahead': '领先 {count}',
|
|
165
|
+
'chip.behind': '落后 {count}',
|
|
166
|
+
'chip.private': '私有',
|
|
167
|
+
'state.loading': '正在读取…',
|
|
168
|
+
'state.reading': '读取中…',
|
|
169
|
+
'state.hostGone': '无法连接 Host;面板会保留上一次的数据,稍后自动重试。',
|
|
170
|
+
'state.timeout': '请求超时,Host 没有在时限内回答。',
|
|
171
|
+
'stale.title': '页面与宿主半边版本不一致',
|
|
172
|
+
'stale.text': '页面已经是最新的,但运行中的宿主半边还是旧版本——它里面没有这条接口,所以按钮会报 401/404。刷新页面不会更新宿主。',
|
|
173
|
+
'stale.how': '点侧边栏底部的一键重启(或重启 DeepSeek Harness)之后,两半就一致了。',
|
|
174
|
+
'state.noRuns': '还没有运行记录',
|
|
175
|
+
'state.noReleases': '还没有 Release',
|
|
176
|
+
'state.dispatched': '已触发 {workflow}({repo})。',
|
|
177
|
+
'state.releaseDispatched': '已触发发布流程({repo})。它产出的是草稿——但草稿是否真的出现,要看这次运行的结果:失败时展开该行点【日志】就能看到原因(最常见的是版本号没升)。',
|
|
178
|
+
'state.releaseBumped': '已把 {from} 升到 {tag} 并推送 {branch},发布流程也已触发。产出的是草稿,是否成功看这次运行的结果。',
|
|
179
|
+
'state.committed': '已提交并推送到 {branch}({count} 个改动)。现在 构建 与 发布 都能看到它们了。',
|
|
180
|
+
'state.pushed': '已把 {count} 个本地提交推送到 {branch}。',
|
|
181
|
+
'commit.noCheckout': '这个仓库没有可用的本地检出,所以没有可提交的东西。',
|
|
182
|
+
'commit.willCommit': '将提交 {count} 个改动:',
|
|
183
|
+
'commit.willPush': '工作区干净,有 {count} 个本地提交等着推送——发布构建的是 GitHub 上的提交,不是这台机器上的。',
|
|
184
|
+
'commit.nothing': '工作区干净,且与上游同步:没有可提交或推送的东西。',
|
|
185
|
+
'commit.messagePlaceholder': '提交信息',
|
|
186
|
+
'commit.messageRequired': '提交信息不能为空。',
|
|
187
|
+
'confirm.push': '把 {count} 个本地提交推送到 {branch}?',
|
|
188
|
+
'state.published': '{tag} 已公开。',
|
|
189
|
+
'state.rerun': '已请求重跑。',
|
|
190
|
+
'state.cancelled': '已请求取消。',
|
|
191
|
+
'state.actionFailed': '操作失败:{reason}',
|
|
192
|
+
'state.registered': '已添加 {repo}。',
|
|
193
|
+
'state.removed': '已移除 {repo}。',
|
|
194
|
+
'state.truncated': '(只显示最后 {lines} 行)',
|
|
195
|
+
'drafts.title': '有 {count} 个草稿还没公开',
|
|
196
|
+
'drafts.text': '草稿对外不可见,也不创建 tag,所以在仓库的 Releases 页面看不到——这不是失败。展开下面带【草稿】标记的行,点【公开草稿】即可公开发布。',
|
|
197
|
+
'meta.account': '账号',
|
|
198
|
+
'meta.updated': '更新于 {time}',
|
|
199
|
+
'meta.cached': '缓存',
|
|
200
|
+
'meta.version': '本地',
|
|
201
|
+
'meta.expectedTag': '期望 tag {tag}',
|
|
202
|
+
'meta.noLocal': '未配置本地路径,无法对比本地状态',
|
|
203
|
+
'clone.title': '本地检出',
|
|
204
|
+
'clone.why.none': '还没有本地检出。克隆下来之后,提交 / 构建 / 发布才有东西可作用于它——这些动作都需要一份工作区。',
|
|
205
|
+
'clone.why.broken': '配置的路径不可用({path}):{reason}',
|
|
206
|
+
'clone.urlPlaceholder': '仓库地址,如 https://github.com/owner/repo.git',
|
|
207
|
+
'clone.action': '克隆',
|
|
208
|
+
'clone.target': '将克隆到 {path}',
|
|
209
|
+
'clone.noRoot': '还没设置检出根目录:请在 profile 的 cordis.patch.yml 里给 `projectsRoot` 一个绝对路径,否则不知道该克隆到哪里。',
|
|
210
|
+
'clone.done': '已克隆到 {path}。',
|
|
211
|
+
'meta.assets': '{count} 个资产',
|
|
212
|
+
'meta.path': '路径',
|
|
213
|
+
'meta.scopes': '权限',
|
|
214
|
+
'meta.noScopes': '(gh 未报告权限列表)',
|
|
215
|
+
'meta.gh': 'gh',
|
|
216
|
+
'meta.configFrom': '配置来源',
|
|
217
|
+
'meta.projectsRoot': '本地检出根目录',
|
|
218
|
+
'meta.projectsRootUnset': '未设置,添加仓库时无法自动找到本地检出',
|
|
219
|
+
'run.unknown': '未知',
|
|
220
|
+
'release.takenTitle': '发布前检查:这个版本号已经属于另一个提交',
|
|
221
|
+
'release.taken': '{tag} 已属于 {owner},而这次发布会构建 {built}——release.yml 会拒绝覆盖属于另一个提交的同号 Release(否则 tag 与资产会不一致),所以直接发布必然失败。先把它升到 {next} 再发布。',
|
|
222
|
+
'release.takenNoNext': '{tag} 已属于 {owner},而这次发布会构建 {built}——release.yml 会拒绝覆盖属于另一个提交的同号 Release,所以直接发布必然失败。先把版本号升到下一号。',
|
|
223
|
+
'release.dirtyNote': '工作区有 {count} 个未提交改动:发布构建的是已推送的提交,这些改动不会进入发布包(升版本也会因此被拒绝)。',
|
|
224
|
+
'release.aheadNote': '有 {count} 个提交还没推送:发布构建的是远端分支,推送之前它们不会进入发布包。',
|
|
225
|
+
'release.dirty': '工作区有 {count} 个未提交改动,先提交或暂存它们。',
|
|
226
|
+
'release.noUpstream': '分支 {branch} 没有上游,无法推送。',
|
|
227
|
+
'release.behind': '分支落后上游 {count} 个提交,先拉取再发布。',
|
|
228
|
+
'release.noCheckout': '这个仓库没有可用的本地检出(找不到 package.json)。',
|
|
229
|
+
'release.noVersion': '本地读不到可升级的版本号。',
|
|
230
|
+
'release.pushFailed': '本地已提交 {tag},但推送失败:{reason}(提交还在本地,重试推送即可)',
|
|
231
|
+
'release.carried': '这次推送同时带上了此前未推送的 {count} 个提交。',
|
|
232
|
+
'confirm.bumpRelease': '把 package.json 从 {from} 升到 {to}、提交并推送到 {branch},然后触发发布?这会创建一个提交。',
|
|
233
|
+
'confirm.publish': '公开发布 {tag}?发布后任何人可见,无法收回。',
|
|
234
|
+
'confirm.cancel': '再想想',
|
|
235
|
+
'confirm.update': '把 {profile} profile 里的 {package}(现在指向 {spec})换成 Release {tag} 的 tgz 并重装?这会改写该 profile 的依赖条目。',
|
|
236
|
+
'install.title': '安装',
|
|
237
|
+
'install.notInstalled': '{package} 不在 {profile} profile 的依赖里,所以没有"已装的那份"可以更新。',
|
|
238
|
+
'install.noRelease': '这个仓库还没有已发布的 Release(草稿不算),所以没有可以装的东西。',
|
|
239
|
+
'install.current': 'profile 里就是 Release {tag} 的包,已经是最新的。',
|
|
240
|
+
'install.checkout': 'profile 里这份指向本地检出({spec}),跑的不是发布出去的那份。装 Release 会用发布包替换它;要回到开发状态再用 dsh plugin add link:<检出路径> 换回来。',
|
|
241
|
+
'install.ahead': 'profile 里是 v{installed},比这个 Release({tag})新——装它等于用发布包退回到旧版本。',
|
|
242
|
+
'install.differs': 'profile 里是 {installed},与 Release {tag} 不是同一号版本,无法比较先后。',
|
|
243
|
+
'state.updated': '{package} 已从 {from} 换成 {tag} 的发布包。新版本要重启 DSH 才会真正生效。',
|
|
244
|
+
'state.updatedSame': '{package} 已经指向 {tag} 这份发布包了:没有改动,也不需要重启。',
|
|
245
|
+
'npm.title': 'npm',
|
|
246
|
+
'npm.chipPublished': 'npm v{version}',
|
|
247
|
+
'npm.chipPending': 'npm 待推 v{version}',
|
|
248
|
+
'npm.chipUnregistered': 'npm 上还没有',
|
|
249
|
+
'npm.account': 'npm {account}',
|
|
250
|
+
'npm.registry': '源 {registry}',
|
|
251
|
+
'npm.recheck': '重新检测 npm',
|
|
252
|
+
'npm.notLoggedIn': '{command} 还没有登录 {registry}——发布需要 npm 的凭据,而插件自己不保存 token。下面是从零开始的四步。',
|
|
253
|
+
'npm.guide.step1': '① 还没有 npm 账号?',
|
|
254
|
+
'npm.guide.signup': '打开 npmjs.com 注册',
|
|
255
|
+
'npm.guide.emailNote': '注册后**必须**到邮箱点确认链接:npm 不允许未验证邮箱的账号发布。',
|
|
256
|
+
'npm.guide.step2': '② 生成一个 Access Token',
|
|
257
|
+
'npm.guide.tokens': '打开 token 页面',
|
|
258
|
+
'npm.guide.tokenType': '类型只有一种可选:Granular Access Token。Classic token 已于 2025-11-19 被 npm 全部撤销,现在也建不出来——别去找它。',
|
|
259
|
+
'npm.guide.tokenScope': 'Permissions 选 Read and write;Packages 选全部,或只勾这个包(只勾一个包更安全)。',
|
|
260
|
+
'npm.guide.tokenExpiry': '有效期最长 90 天,这是 npm 对可写 token 的硬限制。到期后 token 就失效了,回到第 ② 步再生成一个。',
|
|
261
|
+
'npm.guide.token2fa': '只有"给 CI 用、没有人能输一次性密码"时才勾 Bypass 2FA。在这里推**不要勾**:推送时面板会让你填一次性密码。',
|
|
262
|
+
'npm.guide.blocked': '如果 npmjs.com 在你这台机器上打不开,或者被"我不是机器人"的验证拦住:**换一台设备或换一个网络**(手机流量最省事)打开 npmjs.com 生成 token,再把那串字符粘到第 ③ 步。面板只认这个 token,不在乎它是在哪里生成的——这一步和你本机的浏览器无关。',
|
|
263
|
+
'npm.guide.step3': '③ 粘贴到下面',
|
|
264
|
+
'npm.guide.step3Note': '它会写进 {npmrc}(npm 自己读的用户级配置文件,其他行原样保留);插件不保存 token,请求一返回输入框就清空。',
|
|
265
|
+
'npm.guide.step4': '④ 账号开了 2FA?',
|
|
266
|
+
'npm.guide.step4Note': '推送的确认行里会出现一次性密码输入框,填 6 位数字再点一次【确认推送】即可。',
|
|
267
|
+
'npm.guide.docs': 'npm 官方文档',
|
|
268
|
+
'npm.stepUnknown': '无法自动检测',
|
|
269
|
+
'npm.stepTodo': '待做',
|
|
270
|
+
'npm.stepAccountDone': '✓ 已有账号 {account}',
|
|
271
|
+
'npm.stepTokenDone': '✓ 已有可用的 token',
|
|
272
|
+
'npm.stepWrittenDone': '✓ {npmrc} 里已有凭据',
|
|
273
|
+
'npm.status.title': '凭据状态',
|
|
274
|
+
'npm.status.signedIn': '已登录 {account}',
|
|
275
|
+
'npm.status.credentialPresent': '已有凭据,whoami 未确认',
|
|
276
|
+
'npm.status.none': '还没有凭据',
|
|
277
|
+
'npm.status.unknown': '还没读到 npm 的状态',
|
|
278
|
+
'npm.status.unknownHint': 'Host 还没回答 /npm-status(可能是运行中的宿主半边还是旧版本,也可能面板刚打开)。点「重新检测」。在没有答案之前,这里不替你下结论。',
|
|
279
|
+
'npm.packages.title': '每个包在 npm 上的状态',
|
|
280
|
+
'npm.packages.empty': '还没有登记仓库,所以没有可检查的包。',
|
|
281
|
+
'npm.packages.recheck': '重新检测',
|
|
282
|
+
'npm.package.published': '已在 npm 上',
|
|
283
|
+
'npm.package.unpublished': '待推',
|
|
284
|
+
'npm.package.unregistered': '名字空闲',
|
|
285
|
+
'npm.package.unknown': '读不到',
|
|
286
|
+
'npm.package.local': '本地 {version}',
|
|
287
|
+
'npm.package.latest': 'npm 上是 {latest}',
|
|
288
|
+
'npm.credentialPresentShort': '.npmrc 里已经有 {registry} 的凭据,但 whoami 没有确认它——granular token 是包级范围的,whoami 是用户级端点,这很正常。直接推即可,真伪由推送来判。',
|
|
289
|
+
'npm.moreInSettings': '完整引导与每个包的状态在 设置 → 插件 → DSH 插件发布台 · npm。',
|
|
290
|
+
'npm.ciHint': '要给 CI 自动发布?那不该用这里的 token——npm 支持 trusted publishing(GitHub Actions OIDC),不需要任何长期凭据。',
|
|
291
|
+
'settings.segment.npm': 'npm',
|
|
292
|
+
'settings.npmTitle': 'npm 凭据',
|
|
293
|
+
'settings.npmSubtitle': '发布用 Host 自己的包管理器,凭据走 npm 自己读的 ~/.npmrc——插件不保存任何 token。',
|
|
294
|
+
'settings.npmGuideTitle': '从零开始的四步',
|
|
295
|
+
'npm.credentialPresentTitle': '这个源上已经有一行 token',
|
|
296
|
+
'npm.credentialPresent': '.npmrc 里已经有 {registry} 的凭据,但 whoami 没有确认它。granular token 是包级范围的,而 whoami 是用户级端点——它拒绝一个能正常发布的 token 是正常的。所以推送本身才是真正的验证:直接推,失败时下方会告诉你是要填一次性密码、还是重建 token。',
|
|
297
|
+
'npm.rejectedTitle': '这个 token 没有被接受。按顺序排查:',
|
|
298
|
+
'npm.rejectedCauses': '① 复制不全(前后带了空格或换行);② 权限不是 Read and write;③ 已被撤销、或超过 90 天有效期;④ token 被限制到别的包或组织;⑤ 账号邮箱还没验证。',
|
|
299
|
+
'npm.hint.otp-required': '这个账号要求一次性密码:在推送的确认行里填 6 位数字,再点一次【确认推送】。',
|
|
300
|
+
'npm.hint.email-unverified': '先去邮箱点确认链接——npm 不允许未验证邮箱的账号发布——然后回来重试。',
|
|
301
|
+
'npm.hint.not-logged-in': 'token 无效、过期或已被撤销:照上面第 ② 步重新生成一个(可写 token 最长 90 天)。',
|
|
302
|
+
'npm.hint.already-published': '这个版本已经在 npm 上了。npm 不接受同一版本推两次:先用【升版本并发布】升版本,再推。',
|
|
303
|
+
'npm.hint.payment-required': 'npm 对私有包收费,公开包免费。检查 package.json 里有没有 private 字段、以及发布的是不是公开包。',
|
|
304
|
+
'npm.hint.forbidden': '这个 token 没有被授权发布这个包:检查 token 的 Packages 范围,以及包名是否属于你的账号或组织。',
|
|
305
|
+
'npm.hint.not-found': '源上没有这个包名可发布的位置:确认包名拼写、以及账号或组织名。',
|
|
306
|
+
'npm.hint.rate-limited': '被源限流了,等几分钟再试。',
|
|
307
|
+
'npm.hint.registry-error': '这是源自己出错(5xx),不是你配置的问题,稍后重试。',
|
|
308
|
+
'npm.hint.network': '连不上源:检查网络或代理设置。',
|
|
309
|
+
'npm.hint.timeout': '推送超过时限还没结束:可能是网络慢,也可能源在等你回答什么。重试一次,仍失败就看下面的原话。',
|
|
310
|
+
'npm.hint.unknown': '没识别出具体原因,看下面 npm 的原话。',
|
|
311
|
+
'npm.tokenPlaceholder': 'npm_… 或粘贴 Access Token',
|
|
312
|
+
'npm.writeToken': '写入并验证',
|
|
313
|
+
'npm.tokenWritten': 'token 已写入 {npmrc},当前账号 {account}。',
|
|
314
|
+
'npm.npmrcPath': '配置文件',
|
|
315
|
+
'npm.state.published': '这个版本已经在 npm 上了({version}),没有可推的。',
|
|
316
|
+
'npm.state.unregistered': 'npm 上还没有这个名字:这次推送就是它的首次发布。',
|
|
317
|
+
'npm.state.unpublished': 'npm 上是 {latest},本地这个 {version} 还没推过。',
|
|
318
|
+
'npm.state.unknown': '读不到 npm 的状态({reason}),所以不提供推送——宁可不给按钮,也不给一个注定失败的按钮。',
|
|
319
|
+
'npm.action.publish': '推送到 npm v{version}',
|
|
320
|
+
'npm.action.confirm': '确认推送',
|
|
321
|
+
'npm.otpLabel': '一次性密码',
|
|
322
|
+
'npm.otpHint': '账号开了 2FA 才需要;填一次即可,不会被保存。',
|
|
323
|
+
'npm.otpPlaceholder': '6 位数字',
|
|
324
|
+
'npm.blocked.private-package': 'package.json 里写着 "private": true,npm 会拒绝。这是作者的决定,不是这里该绕过的东西。',
|
|
325
|
+
'npm.blocked.not-logged-in': '还没有登录 npm,先把 token 写进去。',
|
|
326
|
+
'npm.blocked.dirty-tree': '工作区有未提交改动:publish 打包的是工作目录,先提交或暂存(files 白名单挡不住名单目录里的新文件)。',
|
|
327
|
+
'npm.blocked.no-checkout': '这个仓库没有可用的本地检出。',
|
|
328
|
+
'npm.blocked.no-version': 'package.json 里没有版本号。',
|
|
329
|
+
'npm.blocked.no-package-name': '读不到包名。',
|
|
330
|
+
'npm.blocked.already-published': '这个版本已经在 npm 上了(npm 不允许同一版本推第二次),先升版本。',
|
|
331
|
+
'npm.blocked.registry-unreachable': '连不上 npm 源,无法确认这个版本在不在。',
|
|
332
|
+
'confirm.npmPublish': '把 {package}@{version} 推到 {registry}?npm 不允许同一版本推第二次,撤回也只在很短的时间内可行——推上去任何人可见。',
|
|
333
|
+
'state.npmPublished': '已把 {package}@{version} 推到 {registry}。',
|
|
334
|
+
'state.npmFirstPublish': '{package}@{version} 已首次发布到 {registry}。',
|
|
335
|
+
'npm.githubDraft': '注意:GitHub 上这个版本只有草稿(或还没有 Release)——npm 会先于 Release 面世。',
|
|
336
|
+
'state.updatedBuilds': '(注意:这次安装有 {count} 个构建脚本被拦住,没有执行。)',
|
|
337
|
+
'restart.ask': '{package} 已经更新到 {tag},但要重启 DSH 才会用上它——现在重启?',
|
|
338
|
+
'restart.scheduled': '已安排重启:DSH 会在一两秒后关闭并自动重新打开。刷新页面不够,模块已经加载在运行中的进程里。',
|
|
339
|
+
'restart.unavailable': '重启不了:{reason}',
|
|
340
|
+
'setup.needGh': '没有找到 gh CLI,发布台无法访问 GitHub。',
|
|
341
|
+
'setup.needSignIn': '还没有登录 GitHub。登录后这里会列出你的仓库。',
|
|
342
|
+
'setup.needScopes': '当前凭据缺少权限:{scopes}。',
|
|
343
|
+
'setup.scopeHint': '缺 repo 读不到私有仓库,缺 workflow 无法触发构建。',
|
|
344
|
+
'setup.needRepos': '还没有登记仓库。',
|
|
345
|
+
'setup.codeHint': '在浏览器里打开下面的地址,输入这个一次性代码:',
|
|
346
|
+
'setup.waiting': '等待授权…(在浏览器里完成即可,这里会自动继续)',
|
|
347
|
+
'setup.requestingCode': '正在向 GitHub 申请一次性码…',
|
|
348
|
+
'setup.codePending': '授权页面已打开——请把下面出现的码输入进去。码还没到,稍等一下它就会出现。',
|
|
349
|
+
'setup.stalled': '已等待 {seconds} 秒仍没有一次性码:{detail}。最常见的原因是 github.com 不可达(面板读取仓库用的 api.github.com 是另一条线路,可能仍然正常)。你可以继续等——网络恢复后码会自己出现——也可以取消后重试。',
|
|
350
|
+
'setup.stalledNoOutput': 'gh 完全没有输出',
|
|
351
|
+
'setup.stalledQuietOutput': 'gh 只输出了空白内容(通常在重试连接)',
|
|
352
|
+
'setup.stalledSaid': 'gh 说:{text}',
|
|
353
|
+
'setup.unreachableTitle': 'github.com 当前连不上',
|
|
354
|
+
'setup.unreachable': '提示:从本机连不上 github.com:443,而设备码流程需要它——如果一直不出码,多半是这个原因。gh 已经在尝试;若它其实能连上(例如走代理),码仍会出现。',
|
|
355
|
+
'setup.dnsTitle': 'DNS 解析 github.com 失败',
|
|
356
|
+
'setup.dns': '提示:本机解析不出 github.com 的地址,而地址本身是通的(实测直连它的 IP 只需几十毫秒)。gh 也要自行解析域名,所以它连请求都发不出去,这就是一直不出码的原因。换个 DNS 或稍后重试即可;gh 已在尝试,解析一恢复码就会出现。',
|
|
357
|
+
'setup.succeeded': '授权成功,正在读取账号…',
|
|
358
|
+
'setup.failed': '授权未完成:{reason}',
|
|
359
|
+
'setup.expired': '一次性代码已过期,请重新开始。',
|
|
360
|
+
'setup.cancelled': '已取消登录。',
|
|
361
|
+
'setup.advanced': '用命令行配置(可选)',
|
|
362
|
+
'setup.ghInstall': '安装 GitHub CLI(任选其一)',
|
|
363
|
+
'picker.title': '添加仓库',
|
|
364
|
+
'picker.search': '搜索仓库',
|
|
365
|
+
'picker.empty': '没有可添加的仓库(可能是没登录,或账号下没有仓库)。',
|
|
366
|
+
'picker.loading': '正在读取你的仓库…',
|
|
367
|
+
'picker.failed': '读取仓库列表失败:{reason}',
|
|
368
|
+
'picker.local': '本地检出',
|
|
369
|
+
'picker.noLocal': '未找到本地检出',
|
|
370
|
+
'picker.hint': '勾选即添加,取消勾选即移除;改动立刻生效,不需要重启。',
|
|
371
|
+
'picker.reload': '重新载入',
|
|
372
|
+
|
|
373
|
+
/* -- Bilibili update notes ------------------------------------------- */
|
|
374
|
+
'settings.segment.bilibili': 'Bilibili',
|
|
375
|
+
'bili.settings.title': 'B 站更新播报',
|
|
376
|
+
'bili.settings.subtitle': '插件发版后,在对应视频的评论区自动补一条更新说明;凭据只落在插件自己的目录里,插件不回显它。',
|
|
377
|
+
'bili.credential.title': '登录状态',
|
|
378
|
+
'bili.credential.ready': '已登录 {uname}',
|
|
379
|
+
'bili.credential.none': '还没有 B 站凭据',
|
|
380
|
+
'bili.credential.unreadable': '凭据读不出来',
|
|
381
|
+
'bili.credential.incomplete': '凭据不完整',
|
|
382
|
+
'bili.credential.not-logged-in': '这份凭据发不了评论',
|
|
383
|
+
'bili.credential.unverified': '还没有验证这份凭据',
|
|
384
|
+
'bili.credential.none.hint': '发评论走的是 B 站 Web 接口,所以凭据必须是网页登录。biliup 默认写下的 cookies.json 是 APP/TV 登录(platform=BiliTV),Web 会员接口一律回 -101「账号未登录」——它能投稿,但发不了评论。',
|
|
385
|
+
'bili.credential.source': '凭据文件 {path}',
|
|
386
|
+
'bili.credential.external': '这份凭据来自配置指定的外部文件(不是面板写入的):{path}',
|
|
387
|
+
'bili.signIn': '登录 B 站',
|
|
388
|
+
'bili.signIn.hint': '点一下会生成一个二维码:用手机 B 站 App 扫,然后在手机上确认。这里会自动继续,不需要终端。扫不动就用下面粘贴的方式。',
|
|
389
|
+
'bili.qrLabel': 'B 站登录二维码',
|
|
390
|
+
'bili.qrHint': '用手机 B 站 App 扫码。二维码约 3 分钟后过期,过期就再点一次【登录 B 站】。',
|
|
391
|
+
'bili.qrTooLong': '这条登录链接太长,画不成二维码——请改用下面粘贴 SESSDATA 的方式。',
|
|
392
|
+
'bili.waiting': '等待扫码…',
|
|
393
|
+
'bili.waitingScanned': '已扫码,请在手机上确认…',
|
|
394
|
+
'bili.signIn.done': '已登录:{uname}',
|
|
395
|
+
'bili.signIn.doneNoName': '已登录,但没能读到账号名。',
|
|
396
|
+
'bili.signIn.expired': '二维码已过期,重新点一次【登录 B 站】。',
|
|
397
|
+
'bili.cancelSignIn': '取消登录',
|
|
398
|
+
'bili.paste.title': '或者粘贴一次',
|
|
399
|
+
'bili.paste.hint': '浏览器里 F12 → Application → Cookies → bilibili.com,复制 SESSDATA 与 bili_jct(后者就是 csrf)。只写进插件自己的文件,验证通过才保存。',
|
|
400
|
+
'bili.paste.sessdata': 'SESSDATA',
|
|
401
|
+
'bili.paste.csrf': 'bili_jct',
|
|
402
|
+
'bili.paste.save': '保存并验证',
|
|
403
|
+
'bili.paste.saved': '凭据已保存:{uname}',
|
|
404
|
+
'bili.signOut': '退出 B 站登录',
|
|
405
|
+
'bili.signOut.confirm': '确认退出?',
|
|
406
|
+
'bili.signOut.done': '已删除插件保存的 B 站凭据。',
|
|
407
|
+
'bili.signOut.fallback': '注意:还有一份外部凭据继续生效({path}),它属于别的工具,这里没有动它。',
|
|
408
|
+
'bili.bind.title': '绑定的视频',
|
|
409
|
+
'bili.chip.announced': '已播报 {tag}',
|
|
410
|
+
'bili.chip.pending': '待播报 {tag}',
|
|
411
|
+
'bili.bind.placeholder': 'BV 号,如 BV1RopP6FEJp',
|
|
412
|
+
'bili.bind.save': '绑定',
|
|
413
|
+
'bili.bind.unbind': '解绑',
|
|
414
|
+
'bili.bind.locked': '已绑定:要换视频先【解绑】。',
|
|
415
|
+
'bili.bind.done': '已绑定 {bvid}{baseline}',
|
|
416
|
+
'bili.bind.baseline': '(基线 {tag}:只有之后发布的新版本会自动播报)',
|
|
417
|
+
'bili.bind.baselineUnknown': '(读不到 Release 列表,绑定时刻之前的版本都按已公开处理)',
|
|
418
|
+
'bili.unbound.done': '已解绑 {repo} 的 B 站视频,播报记录保留。',
|
|
419
|
+
'bili.auto.on': '自动播报',
|
|
420
|
+
'bili.auto.off': '仅手动',
|
|
421
|
+
'bili.auto.toggle': '切换自动播报',
|
|
422
|
+
'bili.announce': '发更新评论',
|
|
423
|
+
'bili.announce.again': '已播报过,重新预览',
|
|
424
|
+
'bili.announce.preview': '将发送这条评论',
|
|
425
|
+
'bili.announce.confirm': '确认发送',
|
|
426
|
+
'bili.announce.force': '仍然发送',
|
|
427
|
+
'bili.announce.cancel': '取消',
|
|
428
|
+
'bili.announce.sent': '已发送到 {bvid} 的评论区({tag}),并且已经能公开读到。',
|
|
429
|
+
'bili.announce.unverified': 'B 站收下了这条评论({bvid} · {tag}),但公开列表里读不到——可能还在审核,也可能已被移除。要重发就用【仍然发送】。',
|
|
430
|
+
'bili.visible.false': '已发送但读不到',
|
|
431
|
+
'bili.visible.hint': 'B 站接受了这条评论,但公开列表里读不到它(B 站原话:{code})。审核中的和被移除的,从接口上看一模一样。可以用【仍然发送】重发。',
|
|
432
|
+
'bili.announce.previewFailed': '预览失败:{reason}',
|
|
433
|
+
'bili.repo.none': '这个仓库还没有绑定 B 站视频。',
|
|
434
|
+
'bili.state.already': '{tag} 已经播报过了。',
|
|
435
|
+
'bili.state.baseline': '{tag} 在绑定视频之前就已经发布了,不算这次更新。',
|
|
436
|
+
'bili.state.no-release': '还没有已公开的 Release(草稿不算)——先在发布台把草稿公开。',
|
|
437
|
+
'bili.state.gave-up': '这条已经失败 {count} 次,先看看原因再手动重试。',
|
|
438
|
+
'bili.state.unbound': '这个仓库还没有绑定 B 站视频。',
|
|
439
|
+
'bili.needCredential': '有仓库绑定了 B 站视频,但还没有可用的 B 站凭据,更新评论发不出去。',
|
|
440
|
+
'bili.openSettings': '在 设置 → 插件 → DSH 插件发布台 · Bilibili 里管理',
|
|
441
|
+
'bili.lastSweep': '自动巡检 {time}:{summary}',
|
|
442
|
+
'bili.sweep.none': '还没有需要播报的新版本',
|
|
443
|
+
'bili.sweep.announced': '已播报 {repo} {tag}',
|
|
444
|
+
'bili.sweep.failed': '{repo} {tag} 播报失败',
|
|
445
|
+
'bili.neverSwept': '自动巡检还没有跑过',
|
|
446
|
+
'bili.ledger.problem': '播报记录读不出来({problem})。在修好之前不会发送任何评论——否则会把已经发过的评论再发一遍。',
|
|
447
|
+
'bili.template': '模板',
|
|
448
|
+
'bili.template.hint': '可用占位符:{tag} {version} {label} {repo} {title} {summary} {url} {date}。默认不含链接,因为带链接的评论更容易被 B 站过滤。',
|
|
449
|
+
'bili.unknownPlaceholder': '模板里有不认识的占位符:{names}',
|
|
450
|
+
'bili.video.failed': '读不到视频:{reason}',
|
|
451
|
+
'bili.watch.off': '自动巡检已关(bilibiliWatchSeconds: 0),只剩手动按钮与公开草稿后的那一次。',
|
|
452
|
+
'bili.repos.title': '每个仓库',
|
|
453
|
+
'bili.repos.empty': '还没有登记仓库。',
|
|
454
|
+
'bili.announced.title': '已播报',
|
|
455
|
+
'bili.announced.none': '还没有播报过。',
|
|
456
|
+
'bili.failure.last': '上次失败:{message}',
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** English dictionary, same key set. */
|
|
460
|
+
const en = {
|
|
461
|
+
'panel': 'DSH Plugin CI/CD',
|
|
462
|
+
'title': 'DSH Plugin CI/CD',
|
|
463
|
+
'subtitle': 'Continuous builds and releases for DSH plugins',
|
|
464
|
+
'settings.consoleLabel': 'DSH Release Console',
|
|
465
|
+
'settings.segment.github': 'GitHub',
|
|
466
|
+
'settings.title': 'GitHub account',
|
|
467
|
+
'settings.subtitle': 'The console reaches GitHub through the gh CLI on this machine; the plugin stores no credential of its own.',
|
|
468
|
+
'action.refresh': 'Refresh',
|
|
469
|
+
'action.manage': 'Repositories',
|
|
470
|
+
'action.close': 'Done',
|
|
471
|
+
'action.commit': 'Commit',
|
|
472
|
+
'action.push': 'Push',
|
|
473
|
+
'action.commitConfirm': 'Commit',
|
|
474
|
+
'action.pushConfirm': 'Push',
|
|
475
|
+
'action.build': 'Build',
|
|
476
|
+
'action.release': 'Release',
|
|
477
|
+
'action.bumpRelease': 'Bump and release',
|
|
478
|
+
'action.publishDraft': 'Publish',
|
|
479
|
+
'action.confirmPublish': 'Publish?',
|
|
480
|
+
'action.update': 'Update to {tag}',
|
|
481
|
+
'action.installRelease': 'Install release {tag}',
|
|
482
|
+
'action.confirmUpdate': 'Update',
|
|
483
|
+
'action.restartNow': 'Restart DSH now',
|
|
484
|
+
'action.restartLater': 'Later',
|
|
485
|
+
'action.open': 'Open',
|
|
486
|
+
'action.logs': 'Logs',
|
|
487
|
+
'action.hideLogs': 'Hide logs',
|
|
488
|
+
'action.rerun': 'Re-run',
|
|
489
|
+
'action.cancel': 'Cancel',
|
|
490
|
+
'action.expand': 'Details',
|
|
491
|
+
'action.collapse': 'Less',
|
|
492
|
+
'action.add': 'Add',
|
|
493
|
+
'action.remove': 'Remove',
|
|
494
|
+
'action.signIn': 'Sign in with a browser',
|
|
495
|
+
'action.recheck': 'Re-check',
|
|
496
|
+
'action.grantScopes': 'Grant scopes',
|
|
497
|
+
'action.copy': 'Copy',
|
|
498
|
+
'action.copied': 'Copied',
|
|
499
|
+
'action.openDevicePage': 'Open the authorization page',
|
|
500
|
+
'action.cancelSignIn': 'Cancel sign-in',
|
|
501
|
+
'action.signOut': 'Sign out',
|
|
502
|
+
'action.signOutConfirm': 'Confirm sign out?',
|
|
503
|
+
'state.signedOut': 'Signed out: the token was removed from the operating system credential store. git push stops working until you sign in again — git takes its credential from gh.',
|
|
504
|
+
'label.runs': 'Runs',
|
|
505
|
+
'chip.published': 'Released',
|
|
506
|
+
'chip.unpublished': 'Unreleased',
|
|
507
|
+
'chip.draft': 'Draft',
|
|
508
|
+
'chip.versionTaken': 'version taken',
|
|
509
|
+
'chip.update': 'update {tag}',
|
|
510
|
+
'chip.checkout': 'local checkout',
|
|
511
|
+
'chip.dirty': '{count} uncommitted',
|
|
512
|
+
'chip.ahead': '{count} ahead',
|
|
513
|
+
'chip.behind': '{count} behind',
|
|
514
|
+
'chip.private': 'private',
|
|
515
|
+
'state.loading': 'Loading…',
|
|
516
|
+
'state.reading': 'reading…',
|
|
517
|
+
'state.hostGone': 'Cannot reach the Host; the panel keeps the last known data and retries on its own.',
|
|
518
|
+
'state.timeout': 'The request timed out without an answer from the Host.',
|
|
519
|
+
'stale.title': 'The page and the Host half are different versions',
|
|
520
|
+
'stale.text': 'This page is current, but the running Host half is older and does not have this route, so the buttons answer 401/404. Refreshing the page does not replace the Host.',
|
|
521
|
+
'stale.how': 'Use the one-click restart at the foot of the sidebar (or restart DeepSeek Harness) and the two halves match again.',
|
|
522
|
+
'state.noRuns': 'no runs yet',
|
|
523
|
+
'state.noReleases': 'no releases yet',
|
|
524
|
+
'state.dispatched': 'Triggered {workflow} on {repo}.',
|
|
525
|
+
'state.releaseDispatched': 'Release workflow triggered for {repo}. It produces a DRAFT — but whether a draft actually appears depends on that run: if it fails, expand the row and press Logs to see why (a stale version number is the usual reason).',
|
|
526
|
+
'state.releaseBumped': 'Bumped {from} to {tag} and pushed {branch}; the release workflow is triggered. It produces a draft, and whether it succeeds depends on that run.',
|
|
527
|
+
'state.committed': 'Committed and pushed {branch} ({count} change(s)). Build and Release can see them now.',
|
|
528
|
+
'state.pushed': 'Pushed {count} local commit(s) to {branch}.',
|
|
529
|
+
'commit.noCheckout': 'This repository has no usable local checkout, so there is nothing here to commit.',
|
|
530
|
+
'commit.willCommit': 'This will commit {count} change(s):',
|
|
531
|
+
'commit.willPush': 'The tree is clean and {count} local commit(s) are waiting to be pushed — a release builds the commit on GitHub, not the one on this disk.',
|
|
532
|
+
'commit.nothing': 'The tree is clean and in sync with its upstream: nothing to commit or push.',
|
|
533
|
+
'commit.messagePlaceholder': 'Commit message',
|
|
534
|
+
'commit.messageRequired': 'A commit needs a message.',
|
|
535
|
+
'confirm.push': 'Push {count} local commit(s) to {branch}?',
|
|
536
|
+
'state.published': '{tag} is public now.',
|
|
537
|
+
'state.rerun': 'Re-run requested.',
|
|
538
|
+
'state.cancelled': 'Cancellation requested.',
|
|
539
|
+
'state.actionFailed': 'The action failed: {reason}',
|
|
540
|
+
'state.registered': 'Added {repo}.',
|
|
541
|
+
'state.removed': 'Removed {repo}.',
|
|
542
|
+
'state.truncated': '(showing the last {lines} lines)',
|
|
543
|
+
'drafts.title': '{count} draft(s) not published yet',
|
|
544
|
+
'drafts.text': 'A draft is invisible to everyone else and creates no tag, so it does not appear on the repository Releases page — that is not a failure. Expand the rows marked Draft below and press Publish to make one public.',
|
|
545
|
+
'meta.account': 'Account',
|
|
546
|
+
'meta.updated': 'Updated {time}',
|
|
547
|
+
'meta.cached': 'cached',
|
|
548
|
+
'meta.version': 'local',
|
|
549
|
+
'meta.expectedTag': 'expected tag {tag}',
|
|
550
|
+
'meta.noLocal': 'no local path, so local state cannot be compared',
|
|
551
|
+
'clone.title': 'Local checkout',
|
|
552
|
+
'clone.why.none': 'There is no local checkout yet. Cloning one is what gives 提交, 构建 and 发布 something to act on — all three need a working tree.',
|
|
553
|
+
'clone.why.broken': 'The configured path is unusable ({path}): {reason}',
|
|
554
|
+
'clone.urlPlaceholder': 'Repository address, e.g. https://github.com/owner/repo.git',
|
|
555
|
+
'clone.action': 'Clone',
|
|
556
|
+
'clone.target': 'Will clone into {path}',
|
|
557
|
+
'clone.noRoot': 'No checkout root is set: give `projectsRoot` an absolute path in the profile\'s cordis.patch.yml, or there is nowhere to clone into.',
|
|
558
|
+
'clone.done': 'Cloned into {path}.',
|
|
559
|
+
'meta.assets': '{count} assets',
|
|
560
|
+
'meta.path': 'Path',
|
|
561
|
+
'meta.scopes': 'Scopes',
|
|
562
|
+
'meta.noScopes': '(gh reported no scope list)',
|
|
563
|
+
'meta.gh': 'gh',
|
|
564
|
+
'meta.configFrom': 'Config from',
|
|
565
|
+
'meta.projectsRoot': 'Checkout root',
|
|
566
|
+
'meta.projectsRootUnset': 'not set, so adding a repository cannot find its local checkout',
|
|
567
|
+
'run.unknown': 'unknown',
|
|
568
|
+
'release.takenTitle': 'Release preflight: this version already belongs to another commit',
|
|
569
|
+
'release.taken': '{tag} was created from {owner}, but this release would build {built} — release.yml refuses to overwrite a release of the same version that belongs to another commit (the tag and its assets would disagree), so dispatching now cannot succeed. Bump it to {next} first.',
|
|
570
|
+
'release.takenNoNext': '{tag} was created from {owner}, but this release would build {built} — release.yml refuses to overwrite a release of the same version that belongs to another commit, so dispatching now cannot succeed. Bump the version first.',
|
|
571
|
+
'release.dirtyNote': '{count} uncommitted change(s): a release builds the commit that was pushed, so these files would not be in the published package (and a version bump is refused for the same reason).',
|
|
572
|
+
'release.aheadNote': '{count} commit(s) not pushed yet: a release builds the remote branch, so they would not be in the published package until they are pushed.',
|
|
573
|
+
'release.dirty': 'The checkout has {count} uncommitted change(s); commit or stash them first.',
|
|
574
|
+
'release.noUpstream': 'Branch {branch} has no upstream to push to.',
|
|
575
|
+
'release.behind': 'The branch is {count} commit(s) behind its upstream; pull before releasing.',
|
|
576
|
+
'release.noCheckout': 'This repository has no usable local checkout (no package.json).',
|
|
577
|
+
'release.noVersion': 'No bumpable version number was found locally.',
|
|
578
|
+
'release.pushFailed': 'Committed {tag} locally, but the push failed: {reason} (the commit is still local — retry the push)',
|
|
579
|
+
'release.carried': 'This push also delivered {count} commit(s) that were not pushed before.',
|
|
580
|
+
'confirm.bumpRelease': 'Bump package.json from {from} to {to}, commit it, push {branch}, then trigger the release? This creates a commit.',
|
|
581
|
+
'confirm.publish': 'Publish {tag} publicly? Once published, anyone can see it.',
|
|
582
|
+
'confirm.cancel': 'Not yet',
|
|
583
|
+
'confirm.update': 'Replace {package} in the {profile} profile (currently {spec}) with the tgz from release {tag} and reinstall? This rewrites that profile dependency.',
|
|
584
|
+
'install.title': 'Install',
|
|
585
|
+
'install.notInstalled': '{package} is not a dependency of the {profile} profile, so there is no installed copy to update.',
|
|
586
|
+
'install.noRelease': 'This repository has no published release yet (a draft does not count), so there is nothing to install from.',
|
|
587
|
+
'install.current': 'The profile already holds the package from release {tag}; it is current.',
|
|
588
|
+
'install.checkout': 'This copy points at a local checkout ({spec}), so it is not the code anyone downloaded. Installing the release replaces it; switch back with dsh plugin add link:<checkout path>.',
|
|
589
|
+
'install.ahead': 'The profile holds v{installed}, which is newer than this release ({tag}) — installing it would move backwards to the published copy.',
|
|
590
|
+
'install.differs': 'The profile holds {installed}, which is not the same version number as release {tag}, so the two cannot be ordered.',
|
|
591
|
+
'state.updated': '{package} was replaced with the release package {tag}. The new version takes effect only after DSH restarts.',
|
|
592
|
+
'state.updatedSame': '{package} already points at the {tag} release package: nothing changed, and no restart is needed.',
|
|
593
|
+
'npm.title': 'npm',
|
|
594
|
+
'npm.chipPublished': 'npm v{version}',
|
|
595
|
+
'npm.chipPending': 'npm v{version} pending',
|
|
596
|
+
'npm.chipUnregistered': 'not on npm',
|
|
597
|
+
'npm.account': 'npm {account}',
|
|
598
|
+
'npm.registry': 'registry {registry}',
|
|
599
|
+
'npm.recheck': 'Re-check npm',
|
|
600
|
+
'npm.notLoggedIn': '{command} is not signed in to {registry} — publishing needs npm credentials, and this plugin stores no token of its own. Four steps, from nothing.',
|
|
601
|
+
'npm.guide.step1': '(1) No npm account yet?',
|
|
602
|
+
'npm.guide.signup': 'Open npmjs.com to sign up',
|
|
603
|
+
'npm.guide.emailNote': 'You MUST confirm the link npm emails you: an account with an unverified email cannot publish.',
|
|
604
|
+
'npm.guide.step2': '(2) Create an access token',
|
|
605
|
+
'npm.guide.tokens': 'Open the token page',
|
|
606
|
+
'npm.guide.tokenType': 'There is only one kind left: a Granular Access Token. Classic tokens were all revoked on 2025-11-19 and can no longer be created — do not go looking for one.',
|
|
607
|
+
'npm.guide.tokenScope': 'Set Permissions to Read and write; scope Packages to everything, or to just this package (narrower is safer).',
|
|
608
|
+
'npm.guide.tokenExpiry': 'The maximum lifetime is 90 days, which is npm\'s hard limit for a write token. When it expires it simply stops working; come back to step 2 and make another.',
|
|
609
|
+
'npm.guide.token2fa': 'Tick Bypass 2FA only for CI, where nobody can type a one-time password. For pushing here, leave it OFF — the panel will ask for the code.',
|
|
610
|
+
'npm.guide.blocked': 'If npmjs.com will not open on this machine, or an "are you a robot" check blocks it: create the token on ANOTHER device or network (a phone on mobile data is easiest) and paste the string into step 3. The panel only needs the token — where it was made makes no difference, and this step has nothing to do with this machine\'s browser.',
|
|
611
|
+
'npm.guide.step3': '(3) Paste it below',
|
|
612
|
+
'npm.guide.step3Note': 'It is written to {npmrc} (the user-level file npm itself reads; every other line is preserved). This plugin stores no token, and the field is cleared as soon as the request answers.',
|
|
613
|
+
'npm.guide.step4': '(4) Is 2FA on the account?',
|
|
614
|
+
'npm.guide.step4Note': 'The push confirmation grows a one-time password field: enter the 6 digits and press Push again.',
|
|
615
|
+
'npm.guide.docs': 'npm documentation',
|
|
616
|
+
'npm.stepUnknown': 'cannot be detected',
|
|
617
|
+
'npm.stepTodo': 'to do',
|
|
618
|
+
'npm.stepAccountDone': '✓ account {account} exists',
|
|
619
|
+
'npm.stepTokenDone': '✓ a working token exists',
|
|
620
|
+
'npm.stepWrittenDone': '✓ a credential is already in {npmrc}',
|
|
621
|
+
'npm.status.title': 'Credential',
|
|
622
|
+
'npm.status.signedIn': 'signed in as {account}',
|
|
623
|
+
'npm.status.credentialPresent': 'credential present, whoami unconfirmed',
|
|
624
|
+
'npm.status.none': 'no credential yet',
|
|
625
|
+
'npm.status.unknown': 'npm has not answered yet',
|
|
626
|
+
'npm.status.unknownHint': 'The Host has not answered /npm-status (the running Host half may be an older version, or the panel just opened). Press Re-check. Until there is an answer, this page will not draw a conclusion for you.',
|
|
627
|
+
'npm.packages.title': 'Every package on npm',
|
|
628
|
+
'npm.packages.empty': 'No repository is registered, so there is no package to check.',
|
|
629
|
+
'npm.packages.recheck': 'Re-check',
|
|
630
|
+
'npm.package.published': 'on npm',
|
|
631
|
+
'npm.package.unpublished': 'pending',
|
|
632
|
+
'npm.package.unregistered': 'name is free',
|
|
633
|
+
'npm.package.unknown': 'unreadable',
|
|
634
|
+
'npm.package.local': 'local {version}',
|
|
635
|
+
'npm.package.latest': 'npm has {latest}',
|
|
636
|
+
'npm.credentialPresentShort': '.npmrc already carries a credential for {registry}, but whoami would not confirm it — a granular token is scoped to packages while whoami is a user-level endpoint, so that is normal. Just push; the publish decides whether it is real.',
|
|
637
|
+
'npm.moreInSettings': 'The full guide and every package\'s state live in Settings → Plugins → DSH Release Console · npm.',
|
|
638
|
+
'npm.ciHint': 'Publishing from CI? This token is not the way — npm supports trusted publishing (GitHub Actions OIDC), which needs no long-lived credential at all.',
|
|
639
|
+
'settings.segment.npm': 'npm',
|
|
640
|
+
'settings.npmTitle': 'npm credentials',
|
|
641
|
+
'settings.npmSubtitle': 'Publishing uses the Host\'s own package manager, and the credential comes from the ~/.npmrc npm itself reads — this plugin stores no token.',
|
|
642
|
+
'settings.npmGuideTitle': 'Four steps, from nothing',
|
|
643
|
+
'npm.credentialPresentTitle': 'This registry already has a token line',
|
|
644
|
+
'npm.credentialPresent': '.npmrc carries a credential for {registry}, but whoami would not confirm it. A granular token is scoped to PACKAGES while whoami is a user-level endpoint, so refusing a token that publishes perfectly well is normal. The publish itself is the real test: just push, and if it fails the panel says whether to enter a one-time password or make a new token.',
|
|
645
|
+
'npm.rejectedTitle': 'That token was not accepted. Check, in order:',
|
|
646
|
+
'npm.rejectedCauses': '(1) it was copied incompletely (leading or trailing whitespace); (2) Permissions are not Read and write; (3) it was revoked, or passed its 90-day limit; (4) it is scoped to another package or organization; (5) the account email is still unverified.',
|
|
647
|
+
'npm.hint.otp-required': 'This account requires a one-time password: enter the 6 digits in the push confirmation and press Push again.',
|
|
648
|
+
'npm.hint.email-unverified': 'Confirm the link npm emailed you first — an unverified email cannot publish — then try again.',
|
|
649
|
+
'npm.hint.not-logged-in': 'The token is invalid, expired or revoked: make a new one as in step 2 (a write token lasts at most 90 days).',
|
|
650
|
+
'npm.hint.already-published': 'This version is already on npm, which never accepts the same version twice: bump the version first, then push.',
|
|
651
|
+
'npm.hint.payment-required': 'npm charges for private packages and not for public ones. Check for a private flag in package.json and whether this is meant to be public.',
|
|
652
|
+
'npm.hint.forbidden': 'This token is not allowed to publish this package: check its Packages scope, and whether the name belongs to your account or organization.',
|
|
653
|
+
'npm.hint.not-found': 'The registry has no publishable place for that name: check the spelling and the account or organization.',
|
|
654
|
+
'npm.hint.rate-limited': 'The registry is rate-limiting you; try again in a few minutes.',
|
|
655
|
+
'npm.hint.registry-error': 'The registry itself failed (5xx). That is not your configuration — try again shortly.',
|
|
656
|
+
'npm.hint.network': 'The registry cannot be reached: check the network or proxy settings.',
|
|
657
|
+
'npm.hint.timeout': 'The publish did not finish in time: a slow network, or the registry waiting for an answer. Retry once, and read its own words below if it fails again.',
|
|
658
|
+
'npm.hint.unknown': 'No specific cause was recognised; npm\'s own words are below.',
|
|
659
|
+
'npm.tokenPlaceholder': 'npm_… or paste an access token',
|
|
660
|
+
'npm.writeToken': 'Write and verify',
|
|
661
|
+
'npm.tokenWritten': 'The token is in {npmrc}; signed in as {account}.',
|
|
662
|
+
'npm.npmrcPath': 'config file',
|
|
663
|
+
'npm.state.published': 'This version is already on npm ({version}); there is nothing to push.',
|
|
664
|
+
'npm.state.unregistered': 'Nobody owns this name on npm yet: this push would be its first release.',
|
|
665
|
+
'npm.state.unpublished': 'npm has {latest}; the local {version} has not been pushed.',
|
|
666
|
+
'npm.state.unknown': 'The npm state cannot be read ({reason}), so no push is offered — a missing button beats one that must fail.',
|
|
667
|
+
'npm.action.publish': 'Push to npm v{version}',
|
|
668
|
+
'npm.action.confirm': 'Push',
|
|
669
|
+
'npm.otpLabel': 'One-time password',
|
|
670
|
+
'npm.otpHint': 'Only needed with 2FA on the account, and it is never stored.',
|
|
671
|
+
'npm.otpPlaceholder': '6 digits',
|
|
672
|
+
'npm.blocked.private-package': 'package.json says "private": true, which npm refuses. That is the author\'s decision, not something to work around here.',
|
|
673
|
+
'npm.blocked.not-logged-in': 'Not signed in to npm yet; write a token above first.',
|
|
674
|
+
'npm.blocked.dirty-tree': 'The checkout has uncommitted changes: publish packs the working directory, so commit or stash first (the files allow-list does not protect a new file inside a listed directory).',
|
|
675
|
+
'npm.blocked.no-checkout': 'This repository has no usable local checkout.',
|
|
676
|
+
'npm.blocked.no-version': 'package.json has no version.',
|
|
677
|
+
'npm.blocked.no-package-name': 'No package name could be read.',
|
|
678
|
+
'npm.blocked.already-published': 'This version is already on npm, which never accepts the same version twice — bump the version first.',
|
|
679
|
+
'npm.blocked.registry-unreachable': 'The npm registry cannot be reached, so whether this version exists is unknown.',
|
|
680
|
+
'confirm.npmPublish': 'Push {package}@{version} to {registry}? npm never accepts the same version twice, and unpublishing is only possible briefly — once it is up, anyone can see it.',
|
|
681
|
+
'state.npmPublished': 'Pushed {package}@{version} to {registry}.',
|
|
682
|
+
'state.npmFirstPublish': '{package}@{version} is now published to {registry} for the first time.',
|
|
683
|
+
'npm.githubDraft': 'Note: GitHub has only a draft (or no release) for this version — npm would go public before the Release does.',
|
|
684
|
+
'state.updatedBuilds': '(Note: {count} build script(s) were blocked and did not run.)',
|
|
685
|
+
'restart.ask': '{package} is updated to {tag}, but DSH has to restart to use it — restart now?',
|
|
686
|
+
'restart.scheduled': 'Restart scheduled: DSH closes in a second or two and reopens by itself. Refreshing the page is not enough; the module is already loaded in the running process.',
|
|
687
|
+
'restart.unavailable': 'Cannot restart: {reason}',
|
|
688
|
+
'setup.needGh': 'The gh CLI was not found, so the console cannot reach GitHub.',
|
|
689
|
+
'setup.needSignIn': 'Not signed in to GitHub yet. Once you are, your repositories appear here.',
|
|
690
|
+
'setup.needScopes': 'The current credential is missing: {scopes}.',
|
|
691
|
+
'setup.scopeHint': 'Without repo, private repositories cannot be read; without workflow, a build cannot be triggered.',
|
|
692
|
+
'setup.needRepos': 'No repository is registered yet.',
|
|
693
|
+
'setup.codeHint': 'Open the address below in your browser and enter this one-time code:',
|
|
694
|
+
'setup.waiting': 'Waiting for authorization… finish in the browser and this continues by itself.',
|
|
695
|
+
'setup.requestingCode': 'Requesting a one-time code from GitHub…',
|
|
696
|
+
'setup.codePending': 'The authorization page is open — enter the code below once it appears. It has not arrived yet; give it a moment.',
|
|
697
|
+
'setup.stalled': 'No one-time code after {seconds}s: {detail}. The usual cause is that github.com cannot be reached (the panel reads repositories over api.github.com, a different route, so that can still work). You can keep waiting — the code appears once the network recovers — or cancel and try again.',
|
|
698
|
+
'setup.stalledNoOutput': 'gh has printed nothing at all',
|
|
699
|
+
'setup.stalledQuietOutput': 'gh has printed only whitespace (it is usually retrying the connection)',
|
|
700
|
+
'setup.stalledSaid': 'gh said: {text}',
|
|
701
|
+
'setup.unreachableTitle': 'github.com is not reachable right now',
|
|
702
|
+
'setup.unreachable': 'Note: this machine cannot open a connection to github.com:443, which the device-code flow needs — if no code appears, that is the likely reason. gh is already trying; if it can reach GitHub by another route (a proxy, for instance) the code will still appear.',
|
|
703
|
+
'setup.dnsTitle': 'Cannot resolve github.com',
|
|
704
|
+
'setup.dns': 'Note: this machine cannot resolve github.com to an address, while the address itself is reachable (connecting straight to its IP takes tens of milliseconds). gh resolves the name too, so it cannot even send the request — which is why no code appears. A different resolver, or simply retrying later, fixes it; gh is already trying and the code appears as soon as resolution works.',
|
|
705
|
+
'setup.succeeded': 'Authorized. Reading the account…',
|
|
706
|
+
'setup.failed': 'Sign-in did not finish: {reason}',
|
|
707
|
+
'setup.expired': 'The one-time code expired. Start again.',
|
|
708
|
+
'setup.cancelled': 'Sign-in cancelled.',
|
|
709
|
+
'setup.advanced': 'Configure from a command line (optional)',
|
|
710
|
+
'setup.ghInstall': 'Install GitHub CLI (either one)',
|
|
711
|
+
'picker.title': 'Add repositories',
|
|
712
|
+
'picker.search': 'Search repositories',
|
|
713
|
+
'picker.empty': 'Nothing to add (not signed in, or this account has no repositories).',
|
|
714
|
+
'picker.loading': 'Reading your repositories…',
|
|
715
|
+
'picker.failed': 'Reading the repository list failed: {reason}',
|
|
716
|
+
'picker.local': 'local checkout',
|
|
717
|
+
'picker.noLocal': 'no local checkout found',
|
|
718
|
+
'picker.hint': 'Ticking adds, unticking removes; the change applies at once, with no restart.',
|
|
719
|
+
'picker.reload': 'Reload',
|
|
720
|
+
|
|
721
|
+
/* -- Bilibili update notes ------------------------------------------- */
|
|
722
|
+
'settings.segment.bilibili': 'Bilibili',
|
|
723
|
+
'bili.settings.title': 'Bilibili update notes',
|
|
724
|
+
'bili.settings.subtitle': 'When a plugin is released, one short note is added to the comment section of the video that introduces it. The credential lives only in this plugin\'s own directory, and is never echoed back.',
|
|
725
|
+
'bili.credential.title': 'Sign-in',
|
|
726
|
+
'bili.credential.ready': 'Signed in as {uname}',
|
|
727
|
+
'bili.credential.none': 'No Bilibili credential yet',
|
|
728
|
+
'bili.credential.unreadable': 'The credential cannot be read',
|
|
729
|
+
'bili.credential.incomplete': 'The credential is incomplete',
|
|
730
|
+
'bili.credential.not-logged-in': 'This credential cannot comment',
|
|
731
|
+
'bili.credential.unverified': 'Not verified yet',
|
|
732
|
+
'bili.credential.none.hint': 'Commenting is a WEB API, so the credential has to be a web session. The cookies.json biliup writes by default is an APP/TV login (platform=BiliTV): it can upload, and every web member endpoint answers it with -101 账号未登录 — so it cannot comment.',
|
|
733
|
+
'bili.credential.source': 'Credential file {path}',
|
|
734
|
+
'bili.credential.external': 'This credential comes from the external file named in the config, not from this panel: {path}',
|
|
735
|
+
'bili.signIn': 'Sign in to Bilibili',
|
|
736
|
+
'bili.signIn.hint': 'One click produces a code to scan with the Bilibili app, then confirm on the phone. This page continues by itself, with no terminal. If the code will not scan, paste the cookies instead.',
|
|
737
|
+
'bili.qrLabel': 'Bilibili sign-in code',
|
|
738
|
+
'bili.qrHint': 'Scan this with the Bilibili app. It expires after about three minutes; press 【Sign in to Bilibili】 again if it does.',
|
|
739
|
+
'bili.qrTooLong': 'This sign-in URL is too long to draw as a code — paste SESSDATA below instead.',
|
|
740
|
+
'bili.waiting': 'Waiting to be scanned…',
|
|
741
|
+
'bili.waitingScanned': 'Scanned — confirm it on your phone…',
|
|
742
|
+
'bili.signIn.done': 'Signed in: {uname}',
|
|
743
|
+
'bili.signIn.doneNoName': 'Signed in, but the account name could not be read.',
|
|
744
|
+
'bili.signIn.expired': 'The code expired. Press 【Sign in to Bilibili】 again.',
|
|
745
|
+
'bili.cancelSignIn': 'Cancel sign-in',
|
|
746
|
+
'bili.paste.title': 'Or paste one',
|
|
747
|
+
'bili.paste.hint': 'In the browser: F12 → Application → Cookies → bilibili.com, and copy SESSDATA and bili_jct (the latter is the csrf). It is written to this plugin\'s own file, and only after Bilibili accepts it.',
|
|
748
|
+
'bili.paste.sessdata': 'SESSDATA',
|
|
749
|
+
'bili.paste.csrf': 'bili_jct',
|
|
750
|
+
'bili.paste.save': 'Save and verify',
|
|
751
|
+
'bili.paste.saved': 'Credential saved: {uname}',
|
|
752
|
+
'bili.signOut': 'Sign out of Bilibili',
|
|
753
|
+
'bili.signOut.confirm': 'Sign out?',
|
|
754
|
+
'bili.signOut.done': 'The credential this plugin stored has been deleted.',
|
|
755
|
+
'bili.signOut.fallback': 'Note: an external credential is still in use ({path}). It belongs to another tool and was left alone.',
|
|
756
|
+
'bili.bind.title': 'Bound video',
|
|
757
|
+
'bili.chip.announced': 'Announced {tag}',
|
|
758
|
+
'bili.chip.pending': 'Waiting to announce {tag}',
|
|
759
|
+
'bili.bind.placeholder': 'BV id, e.g. BV1RopP6FEJp',
|
|
760
|
+
'bili.bind.save': 'Bind',
|
|
761
|
+
'bili.bind.unbind': 'Unbind',
|
|
762
|
+
'bili.bind.locked': 'Bound: press 【Unbind】 first to change the video.',
|
|
763
|
+
'bili.bind.done': 'Bound {bvid}{baseline}',
|
|
764
|
+
'bili.bind.baseline': ' (baseline {tag}: only releases published after this are announced automatically)',
|
|
765
|
+
'bili.bind.baselineUnknown': ' (the release list could not be read, so everything published before now counts as already public)',
|
|
766
|
+
'bili.unbound.done': 'Unbound the video for {repo}; the announcement history is kept.',
|
|
767
|
+
'bili.auto.on': 'Automatic',
|
|
768
|
+
'bili.auto.off': 'Manual only',
|
|
769
|
+
'bili.auto.toggle': 'Toggle automatic announcing',
|
|
770
|
+
'bili.announce': 'Post update note',
|
|
771
|
+
'bili.announce.again': 'Already announced — preview again',
|
|
772
|
+
'bili.announce.preview': 'This is the comment that would be posted',
|
|
773
|
+
'bili.announce.confirm': 'Post it',
|
|
774
|
+
'bili.announce.force': 'Post anyway',
|
|
775
|
+
'bili.announce.cancel': 'Cancel',
|
|
776
|
+
'bili.announce.sent': 'Posted to the comments under {bvid} ({tag}), and it reads back publicly.',
|
|
777
|
+
'bili.announce.unverified': 'Bilibili accepted this comment ({bvid} · {tag}) but the public listing does not serve it — it may still be in review, or it may have been removed. Use 【Send anyway】 to post it again.',
|
|
778
|
+
'bili.visible.false': 'posted, not readable',
|
|
779
|
+
'bili.visible.hint': 'Bilibili accepted this comment, but the public listing does not serve it (Bilibili said: {code}). A comment in review and a removed one are indistinguishable through the API. Use 【Send anyway】 to post it again.',
|
|
780
|
+
'bili.announce.previewFailed': 'Preview failed: {reason}',
|
|
781
|
+
'bili.repo.none': 'This repository has no video bound.',
|
|
782
|
+
'bili.state.already': '{tag} has already been announced.',
|
|
783
|
+
'bili.state.baseline': '{tag} was published before the video was bound, so it is not this update.',
|
|
784
|
+
'bili.state.no-release': 'There is no published release yet (a draft does not count) — publish the draft from the panel first.',
|
|
785
|
+
'bili.state.gave-up': 'This one has failed {count} times; read why before retrying by hand.',
|
|
786
|
+
'bili.state.unbound': 'This repository has no video bound.',
|
|
787
|
+
'bili.needCredential': 'A repository is bound to a video, but there is no usable Bilibili credential, so update notes cannot be posted.',
|
|
788
|
+
'bili.openSettings': 'Manage under Settings → Plugins → DSH Release Console · Bilibili',
|
|
789
|
+
'bili.lastSweep': 'Sweep {time}: {summary}',
|
|
790
|
+
'bili.sweep.none': 'nothing new to announce',
|
|
791
|
+
'bili.sweep.announced': 'announced {repo} {tag}',
|
|
792
|
+
'bili.sweep.failed': '{repo} {tag} failed to announce',
|
|
793
|
+
'bili.neverSwept': 'The sweep has not run yet',
|
|
794
|
+
'bili.ledger.problem': 'The announcement ledger cannot be read ({problem}). Nothing will be posted until it can — otherwise every comment already posted would be posted again.',
|
|
795
|
+
'bili.template': 'Template',
|
|
796
|
+
'bili.template.hint': 'Placeholders: {tag} {version} {label} {repo} {title} {summary} {url} {date}. The default carries no link, because a comment with a link is filtered more often.',
|
|
797
|
+
'bili.unknownPlaceholder': 'The template uses placeholders that do not exist: {names}',
|
|
798
|
+
'bili.video.failed': 'The video cannot be read: {reason}',
|
|
799
|
+
'bili.watch.off': 'The sweep is off (bilibiliWatchSeconds: 0); only the manual button and the sweep after publishing a draft remain.',
|
|
800
|
+
'bili.repos.title': 'Per repository',
|
|
801
|
+
'bili.repos.empty': 'No repository is registered yet.',
|
|
802
|
+
'bili.announced.title': 'Announced',
|
|
803
|
+
'bili.announced.none': 'Nothing has been announced yet.',
|
|
804
|
+
'bili.failure.last': 'Last failure: {message}',
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/* ------------------------------------------------------------- styles -- */
|
|
808
|
+
|
|
809
|
+
const CSS = `
|
|
810
|
+
/* One scale for the whole panel. Every rule below reads these; nothing picks its
|
|
811
|
+
own size, height or radius, because that is how a UI drifts into six different
|
|
812
|
+
font sizes and three different button heights. */
|
|
813
|
+
.dsc-root {
|
|
814
|
+
--dsc-fs-sm: 11px; /* secondary: meta, chips, hints, detail, logs */
|
|
815
|
+
--dsc-fs-md: 12px; /* primary: rows, buttons, inputs, notices */
|
|
816
|
+
--dsc-fs-lg: 15px; /* titles, and the one-time code */
|
|
817
|
+
--dsc-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
818
|
+
--dsc-ctl-h: 26px; /* every clickable control is this tall */
|
|
819
|
+
--dsc-ctl-r: 6px; /* every control, code block and chip container */
|
|
820
|
+
--dsc-box-r: 10px; /* every container */
|
|
821
|
+
--dsc-gap: 8px;
|
|
822
|
+
display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 20px;
|
|
823
|
+
height: 100%; overflow: auto;
|
|
824
|
+
font-family: inherit; font-size: var(--dsc-fs-md); line-height: 1.5;
|
|
825
|
+
color: var(--dsw-alias-label-primary);
|
|
826
|
+
}
|
|
827
|
+
.dsc-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
|
828
|
+
.dsc-heading { display: flex; align-items: baseline; gap: var(--dsc-gap); min-width: 0; }
|
|
829
|
+
.dsc-title { font-size: var(--dsc-fs-lg); font-weight: 600; }
|
|
830
|
+
.dsc-subtitle { font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
|
|
831
|
+
.dsc-meta { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
|
|
832
|
+
.dsc-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
833
|
+
|
|
834
|
+
/* One button. data-kind changes colour only; data-square changes width only. */
|
|
835
|
+
.dsc-btn {
|
|
836
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
|
|
837
|
+
height: var(--dsc-ctl-h); padding: 0 10px;
|
|
838
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-ctl-r);
|
|
839
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
|
|
840
|
+
font-family: inherit; font-size: var(--dsc-fs-md); font-weight: 400; line-height: 1;
|
|
841
|
+
cursor: pointer; white-space: nowrap;
|
|
842
|
+
}
|
|
843
|
+
.dsc-btn:hover:not([disabled]) { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
|
|
844
|
+
.dsc-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
845
|
+
.dsc-btn[disabled] { opacity: .5; cursor: default; }
|
|
846
|
+
.dsc-btn[data-kind="primary"] { border-color: transparent; background: var(--dsw-alias-brand-primary); color: #fff; }
|
|
847
|
+
.dsc-btn[data-kind="danger"] { border-color: transparent; background: var(--dsw-alias-state-error-primary); color: #fff; }
|
|
848
|
+
.dsc-btn[data-kind="quiet"] { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
|
|
849
|
+
.dsc-btn[data-kind="quiet"]:hover:not([disabled]) { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
|
|
850
|
+
.dsc-btn[data-square="true"] { width: var(--dsc-ctl-h); padding: 0; }
|
|
851
|
+
|
|
852
|
+
.dsc-chip {
|
|
853
|
+
display: inline-flex; align-items: center; gap: 4px; height: 18px; padding: 0 7px;
|
|
854
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: 999px;
|
|
855
|
+
font-size: var(--dsc-fs-sm); line-height: 1; color: var(--dsw-alias-label-secondary); white-space: nowrap; flex: none;
|
|
856
|
+
}
|
|
857
|
+
.dsc-chip[data-state="success"] { color: var(--dsw-alias-state-success-primary); border-color: currentColor; }
|
|
858
|
+
.dsc-chip[data-state="error"] { color: var(--dsw-alias-state-error-primary); border-color: currentColor; }
|
|
859
|
+
.dsc-chip[data-state="warn"] { color: var(--dsw-alias-state-warn-primary); border-color: currentColor; }
|
|
860
|
+
.dsc-chip[data-state="busy"] { color: var(--dsw-alias-brand-primary); border-color: currentColor; }
|
|
861
|
+
.dsc-chip[data-state="idle"] { color: var(--dsw-alias-state-idle-primary); border-color: currentColor; }
|
|
862
|
+
.dsc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); flex: none; }
|
|
863
|
+
.dsc-dot[data-state="success"] { background: var(--dsw-alias-state-success-primary); }
|
|
864
|
+
.dsc-dot[data-state="error"] { background: var(--dsw-alias-state-error-primary); }
|
|
865
|
+
.dsc-dot[data-state="warn"] { background: var(--dsw-alias-state-warn-primary); }
|
|
866
|
+
.dsc-dot[data-state="busy"] { background: var(--dsw-alias-brand-primary); }
|
|
867
|
+
|
|
868
|
+
/* One container, one row per repository, separators instead of card gaps. */
|
|
869
|
+
.dsc-list { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
|
|
870
|
+
.dsc-item { border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
871
|
+
.dsc-item:first-child { border-top: none; }
|
|
872
|
+
.dsc-row { display: flex; align-items: center; gap: var(--dsc-gap); padding: 5px 10px; min-height: 32px; }
|
|
873
|
+
.dsc-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 70px; flex: 0 1 auto; }
|
|
874
|
+
.dsc-grow { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); font-size: var(--dsc-fs-sm); }
|
|
875
|
+
.dsc-right { margin-left: auto; display: flex; align-items: center; gap: 6px; flex: none; }
|
|
876
|
+
.dsc-detail { padding: 4px 10px 10px 28px; display: flex; flex-direction: column; gap: var(--dsc-gap); font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
|
|
877
|
+
.dsc-mono { font-family: var(--dsc-mono); overflow-wrap: anywhere; }
|
|
878
|
+
.dsc-sub { display: flex; flex-direction: column; gap: 3px; }
|
|
879
|
+
.dsc-line { display: flex; align-items: center; gap: var(--dsc-gap); padding: 3px 0; border-top: 1px dashed var(--dsw-alias-border-l1); }
|
|
880
|
+
.dsc-line:first-child { border-top: none; }
|
|
881
|
+
.dsc-empty { padding: var(--dsc-gap) 2px; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
|
|
882
|
+
/* A line that only points somewhere else: quieter than a warning, and never a
|
|
883
|
+
control, so it cannot be mistaken for one. */
|
|
884
|
+
.dsc-quiet { font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
|
|
885
|
+
/* The commit confirmation: a sentence, the file list, a message field, then the two
|
|
886
|
+
buttons. A column, because the list wraps and a row would push the buttons off. */
|
|
887
|
+
.dsc-commit { display: flex; flex-direction: column; gap: var(--dsc-gap); padding: var(--dsc-gap) 0; font-size: var(--dsc-fs-sm); }
|
|
888
|
+
/* The sign-in code. White behind, black modules, fixed size whatever the theme is:
|
|
889
|
+
a QR is read by a camera, so it cannot follow a dark theme's colours the way the
|
|
890
|
+
rest of this panel does. */
|
|
891
|
+
.dsc-scan { display: flex; flex-direction: column; gap: 6px; padding: var(--dsc-gap) 0; align-items: flex-start; }
|
|
892
|
+
/* Sized for the WORST case the encoder can produce: version 10 is 57 modules across, so
|
|
893
|
+
220px leaves about 3.8px per module — and the common login URL is version 8 (49
|
|
894
|
+
modules, about 4.5px). A camera needs the modules to be separable, and the only person
|
|
895
|
+
who can check that is holding the phone. */
|
|
896
|
+
.dsc-qr { width: 220px; height: 220px; background: #fff; padding: 8px; border-radius: 4px; box-sizing: content-box; }
|
|
897
|
+
.dsc-qr rect { fill: #000; }
|
|
898
|
+
/* The console's own segment strip, inside its one Plugins tab. Left-aligned and with a
|
|
899
|
+
rule under it, so it reads as "which part of this page" rather than as a second set
|
|
900
|
+
of tabs floating in the middle: the dsc-bar rule alone would spread three buttons
|
|
901
|
+
across the full width. No backticks in here — this whole sheet is one template
|
|
902
|
+
literal, and a backtick ends it. */
|
|
903
|
+
.dsc-segments { justify-content: flex-start; gap: 6px; padding-bottom: var(--dsc-gap); border-bottom: 1px solid var(--dsw-alias-border-l1); margin-bottom: var(--dsc-gap); }
|
|
904
|
+
.dsc-settings { display: flex; flex-direction: column; gap: var(--dsc-gap); }
|
|
905
|
+
|
|
906
|
+
.dsc-notice, .dsc-warn, .dsc-error {
|
|
907
|
+
padding: 6px 10px; border-radius: var(--dsc-ctl-r); font-size: var(--dsc-fs-md); line-height: 1.5;
|
|
908
|
+
border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
|
|
909
|
+
}
|
|
910
|
+
.dsc-warn { color: var(--dsw-alias-state-warn-primary); border-color: currentColor; }
|
|
911
|
+
.dsc-error { color: var(--dsw-alias-state-error-primary); border-color: currentColor; }
|
|
912
|
+
.dsc-notice code, .dsc-setup code, .dsc-cmd code {
|
|
913
|
+
font-family: var(--dsc-mono); font-size: var(--dsc-fs-sm);
|
|
914
|
+
background: var(--dsw-alias-bg-layer-2); padding: 1px 5px; border-radius: 4px;
|
|
915
|
+
}
|
|
916
|
+
.dsc-setup {
|
|
917
|
+
border: 1px solid var(--dsw-alias-brand-primary); border-radius: var(--dsc-box-r);
|
|
918
|
+
background: var(--dsw-alias-bg-layer-1); padding: 10px 12px;
|
|
919
|
+
display: flex; flex-direction: column; gap: var(--dsc-gap);
|
|
920
|
+
font-size: var(--dsc-fs-md); color: var(--dsw-alias-label-secondary);
|
|
921
|
+
}
|
|
922
|
+
.dsc-setup-line { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; }
|
|
923
|
+
.dsc-setup-strong { color: var(--dsw-alias-label-primary); }
|
|
924
|
+
.dsc-code { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 7px 10px; border-radius: var(--dsc-ctl-r); background: var(--dsw-alias-bg-layer-2); }
|
|
925
|
+
.dsc-code-value { font-family: var(--dsc-mono); font-size: var(--dsc-fs-lg); letter-spacing: .12em; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
926
|
+
.dsc-cmd { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; padding: 5px 8px; border-radius: var(--dsc-ctl-r); background: var(--dsw-alias-bg-layer-2); }
|
|
927
|
+
.dsc-cmd code { flex: 1 1 260px; min-width: 0; overflow-wrap: anywhere; }
|
|
928
|
+
.dsc-logs {
|
|
929
|
+
margin: 0; padding: var(--dsc-gap) 10px; max-height: 200px; overflow: auto; border-radius: var(--dsc-ctl-r);
|
|
930
|
+
background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary);
|
|
931
|
+
font-family: var(--dsc-mono); font-size: var(--dsc-fs-sm); line-height: 1.5; white-space: pre-wrap;
|
|
932
|
+
}
|
|
933
|
+
.dsc-picker { border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-box-r); background: var(--dsw-alias-bg-layer-1); display: flex; flex-direction: column; overflow: hidden; }
|
|
934
|
+
.dsc-picker-head { display: flex; align-items: center; gap: var(--dsc-gap); padding: var(--dsc-gap) 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
935
|
+
.dsc-input {
|
|
936
|
+
flex: 1 1 auto; min-width: 0; height: var(--dsc-ctl-h); padding: 0 10px;
|
|
937
|
+
font-family: inherit; font-size: var(--dsc-fs-md); line-height: 1;
|
|
938
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-ctl-r);
|
|
939
|
+
background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary);
|
|
940
|
+
}
|
|
941
|
+
.dsc-input:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
942
|
+
/* The one-time password field is a control, not a form field: it shares the control
|
|
943
|
+
height and radius so it sits on a confirmation line with the buttons. */
|
|
944
|
+
.dsc-otp { flex: 0 0 110px; min-width: 0; }
|
|
945
|
+
.dsc-picker-list { max-height: 260px; overflow: auto; }
|
|
946
|
+
.dsc-picker-row { display: flex; align-items: center; gap: var(--dsc-gap); padding: 5px 10px; border-top: 1px solid var(--dsw-alias-border-l1); font-size: var(--dsc-fs-md); cursor: pointer; }
|
|
947
|
+
.dsc-picker-row:first-child { border-top: none; }
|
|
948
|
+
.dsc-picker-row:hover { background: var(--dsw-alias-bg-layer-2); }
|
|
949
|
+
.dsc-check { width: 14px; height: 14px; flex: none; accent-color: var(--dsw-alias-brand-primary); }
|
|
950
|
+
.dsc-hint { padding: 7px 10px; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
951
|
+
`
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Inject the stylesheet once per document.
|
|
955
|
+
*
|
|
956
|
+
* The loader's materialize step claims every `<style>` that carries no
|
|
957
|
+
* `data-plugin` attribute for the package being materialized, and a later HMR
|
|
958
|
+
* replace of THAT package deletes what it claimed. Tagging the element with
|
|
959
|
+
* this package's own identity keeps it out of both paths.
|
|
960
|
+
*/
|
|
961
|
+
function ensureStyles() {
|
|
962
|
+
if (document.getElementById(STYLE_ID) !== null) return
|
|
963
|
+
const style = document.createElement('style')
|
|
964
|
+
style.id = STYLE_ID
|
|
965
|
+
style.setAttribute('data-plugin', 'dsh-plugin-cicd')
|
|
966
|
+
style.setAttribute('data-plugin-css', STYLE_ID)
|
|
967
|
+
style.textContent = CSS
|
|
968
|
+
document.head.append(style)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** POST one JSON body with a hard timeout; never throws. */
|
|
972
|
+
async function postJson(path, body, timeoutMs) {
|
|
973
|
+
const controller = new AbortController()
|
|
974
|
+
const timer = globalThis.setTimeout(() => {
|
|
975
|
+
controller.abort()
|
|
976
|
+
}, timeoutMs)
|
|
977
|
+
try {
|
|
978
|
+
const response = await fetch(`${BASE}${path}`, {
|
|
979
|
+
method: 'POST',
|
|
980
|
+
headers: { 'content-type': 'application/json' },
|
|
981
|
+
body: JSON.stringify(body ?? {}),
|
|
982
|
+
signal: controller.signal,
|
|
983
|
+
})
|
|
984
|
+
let payload = null
|
|
985
|
+
try {
|
|
986
|
+
payload = await response.json()
|
|
987
|
+
} catch {
|
|
988
|
+
payload = null
|
|
989
|
+
}
|
|
990
|
+
return { ok: true, response, payload }
|
|
991
|
+
} catch (error) {
|
|
992
|
+
return { ok: false, aborted: controller.signal.aborted, error: error instanceof Error ? error.message : String(error) }
|
|
993
|
+
} finally {
|
|
994
|
+
globalThis.clearTimeout(timer)
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** A short local time stamp; an unparseable value is shown as-is. */
|
|
999
|
+
function stamp(iso) {
|
|
1000
|
+
if (typeof iso !== 'string' || iso === '') return ''
|
|
1001
|
+
const parsed = new Date(iso)
|
|
1002
|
+
if (Number.isNaN(parsed.getTime())) return iso
|
|
1003
|
+
return parsed.toLocaleString()
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** A compact "how long did it take" label for a settled run. */
|
|
1007
|
+
function duration(run) {
|
|
1008
|
+
if (run === null || run.createdAt === '' || run.updatedAt === '') return ''
|
|
1009
|
+
const from = new Date(run.createdAt).getTime()
|
|
1010
|
+
const to = new Date(run.updatedAt).getTime()
|
|
1011
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return ''
|
|
1012
|
+
const seconds = Math.round((to - from) / 1000)
|
|
1013
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Map a run's state onto the chip vocabulary. */
|
|
1017
|
+
function runState(run) {
|
|
1018
|
+
if (run === null) return 'idle'
|
|
1019
|
+
if (run.status !== 'completed') return 'busy'
|
|
1020
|
+
if (run.conclusion === 'success') return 'success'
|
|
1021
|
+
if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') return 'error'
|
|
1022
|
+
return 'idle'
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** The 7-character commit form git itself prints, for a message a person reads. */
|
|
1026
|
+
function shortSha(value) {
|
|
1027
|
+
return typeof value === 'string' && value !== '' ? value.slice(0, 7) : '?'
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* A failed release route's reason, in the panel's language.
|
|
1032
|
+
*
|
|
1033
|
+
* The Host answers with a stable `code` plus the facts, and its `message` is the
|
|
1034
|
+
* English fallback a log would show. Rendering the fallback here would put an
|
|
1035
|
+
* English sentence in an otherwise Chinese panel, so the codes that the panel
|
|
1036
|
+
* can explain are translated and anything unknown falls back to the raw message.
|
|
1037
|
+
*
|
|
1038
|
+
* @param {Function} t - dictionary lookup.
|
|
1039
|
+
* @param {object|null} payload - route body.
|
|
1040
|
+
* @param {object|null} response - fetch response, for the status fallback.
|
|
1041
|
+
* @returns {string} a sentence for the error line.
|
|
1042
|
+
*/
|
|
1043
|
+
function releaseFailure(t, payload, response) {
|
|
1044
|
+
const code = typeof payload?.code === 'string' ? payload.code : ''
|
|
1045
|
+
const value = payload?.value ?? {}
|
|
1046
|
+
if (code === 'dirty-tree') return t('release.dirty', { count: String(value.dirty ?? '?') })
|
|
1047
|
+
if (code === 'no-upstream') return t('release.noUpstream', { branch: String(value.branch ?? '?') })
|
|
1048
|
+
if (code === 'behind') return t('release.behind', { count: String(value.behind ?? '?') })
|
|
1049
|
+
if (code === 'no-checkout') return t('release.noCheckout')
|
|
1050
|
+
if (code === 'unusable-version' || code === 'unusable-manifest') return t('release.noVersion')
|
|
1051
|
+
if (code === 'push-failed') return t('release.pushFailed', { tag: String(value.tag ?? ''), reason: String(payload?.message ?? '') })
|
|
1052
|
+
if (code === 'version-taken') {
|
|
1053
|
+
return t(value.nextTag === null || value.nextTag === undefined ? 'release.takenNoNext' : 'release.taken', {
|
|
1054
|
+
tag: String(value.tag ?? ''),
|
|
1055
|
+
owner: shortSha(value.owner),
|
|
1056
|
+
built: shortSha(value.built),
|
|
1057
|
+
next: String(value.nextTag ?? ''),
|
|
1058
|
+
})
|
|
1059
|
+
}
|
|
1060
|
+
return typeof payload?.message === 'string' && payload.message !== '' ? payload.message : `HTTP ${String(response?.status ?? '?')}`
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** A compact isometric package, drawn with `currentColor`. */
|
|
1064
|
+
function ConsoleIcon(props) {
|
|
1065
|
+
const size = Number.isFinite(props?.size) ? props.size : 16
|
|
1066
|
+
return h(
|
|
1067
|
+
'svg',
|
|
1068
|
+
{ width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, focusable: 'false', style: { display: 'block' } },
|
|
1069
|
+
h('path', { d: 'M8 1.6 2.4 4.5v7L8 14.4l5.6-2.9v-7L8 1.6Z', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }),
|
|
1070
|
+
h('path', { d: 'M2.6 4.6 8 7.3l5.4-2.7M8 7.3v6.9', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }),
|
|
1071
|
+
)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** One pill. */
|
|
1075
|
+
function Chip(props) {
|
|
1076
|
+
return h('span', { className: 'dsc-chip', 'data-state': props.state ?? 'idle', title: props.title }, props.children)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* One button.
|
|
1081
|
+
*
|
|
1082
|
+
* `kind` changes colour and `square` changes width — nothing is allowed to
|
|
1083
|
+
* change its own height or font size, which is how a toolbar ends up with
|
|
1084
|
+
* three different button sizes.
|
|
1085
|
+
*/
|
|
1086
|
+
function Btn(props) {
|
|
1087
|
+
return h(
|
|
1088
|
+
'button',
|
|
1089
|
+
{
|
|
1090
|
+
type: 'button',
|
|
1091
|
+
className: 'dsc-btn',
|
|
1092
|
+
'data-kind': props.kind,
|
|
1093
|
+
'data-square': props.square === true ? 'true' : undefined,
|
|
1094
|
+
title: props.title,
|
|
1095
|
+
disabled: props.disabled === true,
|
|
1096
|
+
onClick: props.onClick,
|
|
1097
|
+
},
|
|
1098
|
+
props.children,
|
|
1099
|
+
)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** A value with a copy button — the fallback path, never the instruction. */
|
|
1103
|
+
function CopyLine(props) {
|
|
1104
|
+
const { t, command } = props
|
|
1105
|
+
/**
|
|
1106
|
+
* When the value is already on screen, showing it again beside the copy button
|
|
1107
|
+
* just prints the same string twice. The button is then the whole control.
|
|
1108
|
+
*/
|
|
1109
|
+
const hideCommand = props.hideCommand === true
|
|
1110
|
+
const [copied, setCopied] = React.useState(false)
|
|
1111
|
+
React.useEffect(() => {
|
|
1112
|
+
if (!copied) return undefined
|
|
1113
|
+
const timer = globalThis.setTimeout(() => {
|
|
1114
|
+
setCopied(false)
|
|
1115
|
+
}, 2_000)
|
|
1116
|
+
return () => {
|
|
1117
|
+
globalThis.clearTimeout(timer)
|
|
1118
|
+
}
|
|
1119
|
+
}, [copied])
|
|
1120
|
+
return h(
|
|
1121
|
+
'div',
|
|
1122
|
+
{ className: 'dsc-cmd' },
|
|
1123
|
+
hideCommand ? null : h('code', null, command),
|
|
1124
|
+
h(
|
|
1125
|
+
Btn,
|
|
1126
|
+
{
|
|
1127
|
+
kind: 'quiet',
|
|
1128
|
+
onClick: () => {
|
|
1129
|
+
// Clipboard access needs a secure context; the loopback GUI is one,
|
|
1130
|
+
// but a refusal must not be reported as a successful copy.
|
|
1131
|
+
const write = globalThis.navigator?.clipboard?.writeText
|
|
1132
|
+
if (typeof write !== 'function') return
|
|
1133
|
+
void write.call(globalThis.navigator.clipboard, command).then(() => setCopied(true)).catch(() => setCopied(false))
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
copied ? t('action.copied') : t('action.copy'),
|
|
1137
|
+
),
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* The browser sign-in, driven from the panel.
|
|
1143
|
+
*
|
|
1144
|
+
* The Host runs `gh auth login --web` and streams back the one-time code, so
|
|
1145
|
+
* the user's whole job is: click, open the linked page, type the code. No
|
|
1146
|
+
* terminal — and the plugin still never sees the token, because `gh` writes it.
|
|
1147
|
+
*/
|
|
1148
|
+
function SignIn(props) {
|
|
1149
|
+
const { t, status, onChanged } = props
|
|
1150
|
+
const [attempt, setAttempt] = React.useState(status?.auth ?? { state: 'idle' })
|
|
1151
|
+
const [busy, setBusy] = React.useState(false)
|
|
1152
|
+
const [error, setError] = React.useState(null)
|
|
1153
|
+
const gh = status?.gh ?? {}
|
|
1154
|
+
const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
|
|
1155
|
+
|
|
1156
|
+
const start = React.useCallback(
|
|
1157
|
+
async (mode) => {
|
|
1158
|
+
setBusy(true)
|
|
1159
|
+
setError(null)
|
|
1160
|
+
const result = await postJson('/auth-start', { mode, scopes: mode === 'refresh' ? missing : [] }, ACTION_TIMEOUT_MS)
|
|
1161
|
+
setBusy(false)
|
|
1162
|
+
if (!result.ok) {
|
|
1163
|
+
setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
|
|
1164
|
+
return
|
|
1165
|
+
}
|
|
1166
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
1167
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
setAttempt(result.payload.value)
|
|
1171
|
+
},
|
|
1172
|
+
[missing, t],
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
// Poll only while an attempt is pending: waking up every two seconds for the
|
|
1176
|
+
// rest of the session would be work nobody asked for.
|
|
1177
|
+
const pending = attempt.state === 'pending' || attempt.state === 'running'
|
|
1178
|
+
/**
|
|
1179
|
+
* `onChanged` is passed as an inline arrow, so its identity changes on every
|
|
1180
|
+
* render of the parent. Keeping it in a dependency array therefore tore this
|
|
1181
|
+
* interval down and restarted it whenever anything re-rendered — and a timer
|
|
1182
|
+
* that is reset more often than it fires never fires at all, which is how the
|
|
1183
|
+
* one-time code could sit unread in the Host while the panel showed nothing.
|
|
1184
|
+
* A ref carries the latest callback without being a dependency.
|
|
1185
|
+
*/
|
|
1186
|
+
const onChangedRef = React.useRef(onChanged)
|
|
1187
|
+
onChangedRef.current = onChanged
|
|
1188
|
+
React.useEffect(() => {
|
|
1189
|
+
if (!pending) return undefined
|
|
1190
|
+
let cancelled = false
|
|
1191
|
+
/* Ask once immediately: the code usually exists by the time /auth-start
|
|
1192
|
+
answers, so waiting a full interval just to see it is pure latency. */
|
|
1193
|
+
const poll = async () => {
|
|
1194
|
+
const result = await postJson('/auth-state', {}, STATUS_TIMEOUT_MS)
|
|
1195
|
+
if (cancelled || !result.ok || result.payload?.ok !== true) return
|
|
1196
|
+
const next = result.payload.value
|
|
1197
|
+
setAttempt(next)
|
|
1198
|
+
if (next.state === 'succeeded') onChangedRef.current()
|
|
1199
|
+
}
|
|
1200
|
+
void poll()
|
|
1201
|
+
const timer = globalThis.setInterval(() => { void poll() }, AUTH_POLL_MS)
|
|
1202
|
+
return () => {
|
|
1203
|
+
cancelled = true
|
|
1204
|
+
globalThis.clearInterval(timer)
|
|
1205
|
+
}
|
|
1206
|
+
}, [pending])
|
|
1207
|
+
|
|
1208
|
+
const cancel = React.useCallback(async () => {
|
|
1209
|
+
await postJson('/auth-cancel', {}, STATUS_TIMEOUT_MS)
|
|
1210
|
+
setAttempt({ state: 'idle' })
|
|
1211
|
+
}, [])
|
|
1212
|
+
|
|
1213
|
+
const code = typeof attempt.code === 'string' && attempt.code !== '' ? attempt.code : null
|
|
1214
|
+
const url = typeof attempt.url === 'string' && attempt.url !== '' ? attempt.url : 'https://github.com/login/device'
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Nothing left to do.
|
|
1218
|
+
*
|
|
1219
|
+
* Once the credential carries every scope the panel needs, this component has
|
|
1220
|
+
* no button to offer and no status worth reporting. Returning `null` retires
|
|
1221
|
+
* it: leaving a redundant "sign in" button, or the remains of "authorized,
|
|
1222
|
+
* reading the account…", made a finished login look stuck — and it stayed
|
|
1223
|
+
* that way until something else happened to re-render the block.
|
|
1224
|
+
*/
|
|
1225
|
+
if (gh.available === true && gh.authenticated === true && missing.length === 0 && !pending) return null
|
|
1226
|
+
|
|
1227
|
+
return h(
|
|
1228
|
+
'div',
|
|
1229
|
+
{ className: 'dsc-sub' },
|
|
1230
|
+
error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, error) : null,
|
|
1231
|
+
pending
|
|
1232
|
+
? h(
|
|
1233
|
+
React.Fragment,
|
|
1234
|
+
null,
|
|
1235
|
+
h('span', { className: 'dsc-setup-strong' }, code === null ? t('setup.requestingCode') : t('setup.codeHint')),
|
|
1236
|
+
h(
|
|
1237
|
+
'div',
|
|
1238
|
+
{ className: 'dsc-code' },
|
|
1239
|
+
h('span', { className: 'dsc-code-value' }, code ?? '····-····'),
|
|
1240
|
+
/* Opening the page before the code exists is the reported failure:
|
|
1241
|
+
the browser shows GitHub's device page and there is nothing to
|
|
1242
|
+
type. The button therefore stays disabled until there is a code. */
|
|
1243
|
+
/* Always usable: the device page's address is static, so opening it
|
|
1244
|
+
costs nothing and waiting for the code to enable it was the reason
|
|
1245
|
+
nothing at all appeared to happen. */
|
|
1246
|
+
h(Btn, { kind: 'primary', onClick: () => globalThis.open(url, '_blank', 'noopener,noreferrer') }, t('action.openDevicePage')),
|
|
1247
|
+
code !== null ? h(CopyLine, { t, command: code, hideCommand: true }) : null,
|
|
1248
|
+
),
|
|
1249
|
+
h('span', null, code === null ? t('setup.codePending') : t('setup.waiting')),
|
|
1250
|
+
/* Advisory, never a refusal: the probe cannot see a proxy that gh
|
|
1251
|
+
might be using, so this reports a likely cause without stopping
|
|
1252
|
+
an attempt that could still succeed. */
|
|
1253
|
+
pending && attempt.reachable === false
|
|
1254
|
+
? h(
|
|
1255
|
+
'span',
|
|
1256
|
+
{ className: 'dsc-warn' },
|
|
1257
|
+
h('strong', null, t(attempt.reachabilityStage === 'dns' ? 'setup.dnsTitle' : 'setup.unreachableTitle')),
|
|
1258
|
+
' — ',
|
|
1259
|
+
t(attempt.reachabilityStage === 'dns' ? 'setup.dns' : 'setup.unreachable'),
|
|
1260
|
+
)
|
|
1261
|
+
: null,
|
|
1262
|
+
/* A stalled attempt keeps running — the code has been observed to
|
|
1263
|
+
arrive once the network recovers — so this reports the wait
|
|
1264
|
+
instead of declaring a failure the user would have to retry. */
|
|
1265
|
+
pending && attempt.stalled === true
|
|
1266
|
+
? h('span', { className: 'dsc-warn' }, t('setup.stalled', {
|
|
1267
|
+
seconds: String(Math.max(1, Math.round((typeof attempt.waitedMs === 'number' ? attempt.waitedMs : 0) / 1000))),
|
|
1268
|
+
detail: typeof attempt.outputExcerpt === 'string' && attempt.outputExcerpt !== ''
|
|
1269
|
+
? t('setup.stalledSaid', { text: attempt.outputExcerpt })
|
|
1270
|
+
: (typeof attempt.outputBytes === 'number' && attempt.outputBytes > 0 ? t('setup.stalledQuietOutput') : t('setup.stalledNoOutput')),
|
|
1271
|
+
}))
|
|
1272
|
+
: null,
|
|
1273
|
+
h('div', { className: 'dsc-actions' }, h(Btn, { onClick: cancel }, t('action.cancelSignIn'))),
|
|
1274
|
+
)
|
|
1275
|
+
: h(
|
|
1276
|
+
'div',
|
|
1277
|
+
{ className: 'dsc-actions' },
|
|
1278
|
+
gh.available !== true
|
|
1279
|
+
? h(Btn, { kind: 'primary', onClick: () => globalThis.open('https://cli.github.com/', '_blank', 'noopener,noreferrer') }, 'cli.github.com')
|
|
1280
|
+
: missing.length > 0
|
|
1281
|
+
? h(Btn, { kind: 'primary', disabled: busy, onClick: () => void start('refresh') }, t('action.grantScopes'))
|
|
1282
|
+
/*
|
|
1283
|
+
* Open the page from inside the click.
|
|
1284
|
+
*
|
|
1285
|
+
* The address is static and does not depend on the code, and opening
|
|
1286
|
+
* it here is what makes the click keep its promise. It has to happen
|
|
1287
|
+
* synchronously in the handler: a window opened from an effect after
|
|
1288
|
+
* an await is outside the user gesture and browsers block it. gh's
|
|
1289
|
+
* own browser launch does not happen until it has a code — which is
|
|
1290
|
+
* precisely the situation where nothing seems to happen.
|
|
1291
|
+
*/
|
|
1292
|
+
: h(Btn, { kind: 'primary', disabled: busy, onClick: () => {
|
|
1293
|
+
globalThis.open('https://github.com/login/device', '_blank', 'noopener,noreferrer')
|
|
1294
|
+
void start('login')
|
|
1295
|
+
} }, t('action.signIn')),
|
|
1296
|
+
h(Btn, { disabled: busy, onClick: onChanged }, t('action.recheck')),
|
|
1297
|
+
),
|
|
1298
|
+
attempt.state === 'succeeded' ? h('span', null, t('setup.succeeded')) : null,
|
|
1299
|
+
attempt.state === 'expired' ? h('span', { className: 'dsc-warn' }, t('setup.expired')) : null,
|
|
1300
|
+
attempt.state === 'cancelled' ? h('span', null, t('setup.cancelled')) : null,
|
|
1301
|
+
attempt.state === 'failed' ? h('span', { className: 'dsc-warn' }, t('setup.failed', { reason: attempt.message ?? t('run.unknown') })) : null,
|
|
1302
|
+
)
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* The repository list: fetched from GitHub, ticked to register.
|
|
1307
|
+
*
|
|
1308
|
+
* This is what replaced "run `configure.mjs add <repo> --path <absolute dir>`".
|
|
1309
|
+
* The local checkout is looked up from the configured root, so the user ticks a
|
|
1310
|
+
* box and types nothing.
|
|
1311
|
+
*/
|
|
1312
|
+
function RepoPicker(props) {
|
|
1313
|
+
const { t, onChanged } = props
|
|
1314
|
+
const [repos, setRepos] = React.useState(null)
|
|
1315
|
+
const [busy, setBusy] = React.useState('')
|
|
1316
|
+
const [error, setError] = React.useState(null)
|
|
1317
|
+
const [filter, setFilter] = React.useState('')
|
|
1318
|
+
|
|
1319
|
+
const load = React.useCallback(async () => {
|
|
1320
|
+
setError(null)
|
|
1321
|
+
setRepos(null)
|
|
1322
|
+
const result = await postJson('/repos-available', { limit: 200 }, OVERVIEW_TIMEOUT_MS)
|
|
1323
|
+
if (!result.ok) {
|
|
1324
|
+
setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1327
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
1328
|
+
// A missing route is a version mismatch, not a failure of the account.
|
|
1329
|
+
setError(isMissingRoute(result.response.status)
|
|
1330
|
+
? t('stale.text')
|
|
1331
|
+
: t('picker.failed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
1332
|
+
return
|
|
1333
|
+
}
|
|
1334
|
+
setRepos(result.payload.value.repos ?? [])
|
|
1335
|
+
}, [t])
|
|
1336
|
+
|
|
1337
|
+
React.useEffect(() => {
|
|
1338
|
+
void load()
|
|
1339
|
+
}, [load])
|
|
1340
|
+
|
|
1341
|
+
const toggle = React.useCallback(
|
|
1342
|
+
async (entry) => {
|
|
1343
|
+
setBusy(entry.fullName)
|
|
1344
|
+
setError(null)
|
|
1345
|
+
/**
|
|
1346
|
+
* Add with the full `owner/name`: the picker knows it, and it is
|
|
1347
|
+
* self-sufficient — a fresh install has no `owner` configured, and a bare
|
|
1348
|
+
* name would then register something the Host cannot resolve to a slug.
|
|
1349
|
+
* Remove with whatever form is actually stored.
|
|
1350
|
+
*/
|
|
1351
|
+
const target = entry.registered && typeof entry.registeredAs === 'string' ? entry.registeredAs : entry.fullName
|
|
1352
|
+
const result = entry.registered
|
|
1353
|
+
? await postJson('/config-remove', { repo: target }, ACTION_TIMEOUT_MS)
|
|
1354
|
+
: await postJson('/config-add', { repo: target }, ACTION_TIMEOUT_MS)
|
|
1355
|
+
setBusy('')
|
|
1356
|
+
if (!result.ok) {
|
|
1357
|
+
setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
1361
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
setRepos((current) => (current ?? []).map((item) => (item.fullName === entry.fullName ? { ...item, registered: !entry.registered } : item)))
|
|
1365
|
+
onChanged()
|
|
1366
|
+
},
|
|
1367
|
+
[onChanged, t],
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
const needle = filter.trim().toLowerCase()
|
|
1371
|
+
const visible = (repos ?? []).filter((entry) => needle === ''
|
|
1372
|
+
|| entry.fullName.toLowerCase().includes(needle)
|
|
1373
|
+
|| entry.description.toLowerCase().includes(needle))
|
|
1374
|
+
|
|
1375
|
+
return h(
|
|
1376
|
+
'div',
|
|
1377
|
+
{ className: 'dsc-picker' },
|
|
1378
|
+
h(
|
|
1379
|
+
'div',
|
|
1380
|
+
{ className: 'dsc-picker-head' },
|
|
1381
|
+
h('input', {
|
|
1382
|
+
className: 'dsc-input',
|
|
1383
|
+
type: 'search',
|
|
1384
|
+
value: filter,
|
|
1385
|
+
placeholder: t('picker.search'),
|
|
1386
|
+
onChange: (event) => setFilter(event.target.value),
|
|
1387
|
+
}),
|
|
1388
|
+
h(Btn, { onClick: () => void load() }, t('picker.reload')),
|
|
1389
|
+
),
|
|
1390
|
+
error !== null ? h('div', { className: 'dsc-warn' }, error) : null,
|
|
1391
|
+
repos === null
|
|
1392
|
+
? h('div', { className: 'dsc-empty' }, t('picker.loading'))
|
|
1393
|
+
: visible.length === 0
|
|
1394
|
+
? h('div', { className: 'dsc-empty' }, t('picker.empty'))
|
|
1395
|
+
: h(
|
|
1396
|
+
'div',
|
|
1397
|
+
{ className: 'dsc-picker-list' },
|
|
1398
|
+
visible.map((entry) =>
|
|
1399
|
+
h(
|
|
1400
|
+
'label',
|
|
1401
|
+
{ className: 'dsc-picker-row', key: entry.fullName },
|
|
1402
|
+
h('input', {
|
|
1403
|
+
className: 'dsc-check',
|
|
1404
|
+
type: 'checkbox',
|
|
1405
|
+
checked: entry.registered,
|
|
1406
|
+
disabled: busy !== '',
|
|
1407
|
+
onChange: () => void toggle(entry),
|
|
1408
|
+
}),
|
|
1409
|
+
h('span', { className: 'dsc-name', style: { minWidth: '0' } }, entry.bare),
|
|
1410
|
+
entry.private ? h(Chip, { state: 'idle' }, t('chip.private')) : null,
|
|
1411
|
+
h('span', { className: 'dsc-grow' }, entry.description),
|
|
1412
|
+
entry.localPath !== ''
|
|
1413
|
+
? h(Chip, { state: 'success', title: entry.localPath }, t('picker.local'))
|
|
1414
|
+
: h(Chip, { state: 'idle' }, t('picker.noLocal')),
|
|
1415
|
+
),
|
|
1416
|
+
),
|
|
1417
|
+
),
|
|
1418
|
+
h('div', { className: 'dsc-hint' }, t('picker.hint')),
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** One repository row, expandable into path, releases, runs and logs. */
|
|
1423
|
+
function RepoRow(props) {
|
|
1424
|
+
const { t, data, busy, onAction, onBump, onCommit, onClone, onPublish, onUpdate, npm, onNpmPublish, bili, onBiliBind, onBiliPreview, onBiliAnnounce, logs, onLogs } = props
|
|
1425
|
+
const [open, setOpen] = React.useState(false)
|
|
1426
|
+
/**
|
|
1427
|
+
* A draft release is the panel's one invisible outcome: it exists, it is not
|
|
1428
|
+
* a failure, and it cannot be seen anywhere until it is published. So a row
|
|
1429
|
+
* that has one opens itself — the Publish button is the answer to "why is
|
|
1430
|
+
* this not released", and it should not be behind a click that nobody knows
|
|
1431
|
+
* to make. The effect keys on the tag, so collapsing the row by hand stays
|
|
1432
|
+
* collapsed until the draft state actually changes.
|
|
1433
|
+
*/
|
|
1434
|
+
React.useEffect(() => {
|
|
1435
|
+
if (data.draftTag !== null && data.draftTag !== undefined) setOpen(true)
|
|
1436
|
+
}, [data.draftTag])
|
|
1437
|
+
const [confirming, setConfirming] = React.useState(null)
|
|
1438
|
+
/** The bump confirmation is inline, like publishing a draft: it creates a commit. */
|
|
1439
|
+
const [bumping, setBumping] = React.useState(false)
|
|
1440
|
+
/** 提交 asks first, and asks for a message: it writes a commit on this disk. */
|
|
1441
|
+
const [committing, setCommitting] = React.useState(false)
|
|
1442
|
+
const [commitMessage, setCommitMessage] = React.useState('')
|
|
1443
|
+
/** Installing a release rewrites a profile dependency, so it asks first too. */
|
|
1444
|
+
const [updating, setUpdating] = React.useState(false)
|
|
1445
|
+
/** A publish cannot be undone, so it asks first — and 2FA needs somewhere to type. */
|
|
1446
|
+
const [publishing, setPublishing] = React.useState(false)
|
|
1447
|
+
const [otp, setOtp] = React.useState('')
|
|
1448
|
+
const run = data.latestRun
|
|
1449
|
+
const local = data.local ?? { available: false }
|
|
1450
|
+
const dirty = Number.isFinite(local.dirty) ? local.dirty : null
|
|
1451
|
+
const ahead = Number.isFinite(local.ahead) ? local.ahead : null
|
|
1452
|
+
/*
|
|
1453
|
+
* 提交 is two actions in one button, because they are one trap: a release builds
|
|
1454
|
+
* the PUSHED commit, so work that never left the working tree and work that never
|
|
1455
|
+
* left the machine are equally absent from the released package. Changes → commit
|
|
1456
|
+
* and push. Clean but ahead → push. Neither → nothing to do, and the button says
|
|
1457
|
+
* so instead of offering a call that could only fail.
|
|
1458
|
+
*/
|
|
1459
|
+
const canCommit = local.available === true && dirty !== null && dirty > 0
|
|
1460
|
+
const canPush = local.available === true && dirty === 0 && ahead !== null && ahead > 0
|
|
1461
|
+
const commitLabel = canPush ? 'action.push' : 'action.commit'
|
|
1462
|
+
const branchName = typeof local.branch === 'string' && local.branch !== '' ? local.branch : 'main'
|
|
1463
|
+
const commitExplain = local.available !== true
|
|
1464
|
+
? t('commit.noCheckout')
|
|
1465
|
+
: canCommit
|
|
1466
|
+
? t('commit.willCommit', { count: String(dirty) })
|
|
1467
|
+
: canPush
|
|
1468
|
+
? t('commit.willPush', { count: String(ahead) })
|
|
1469
|
+
: t('commit.nothing')
|
|
1470
|
+
/**
|
|
1471
|
+
* What this profile has installed for this repository's package.
|
|
1472
|
+
*
|
|
1473
|
+
* The Host decides, not the page: the answer depends on the profile this Host
|
|
1474
|
+
* is running from, and a panel that guessed it from the row's version number
|
|
1475
|
+
* would offer "update" where the two copies are the same code and hide it where
|
|
1476
|
+
* they are not (a `link:` checkout and a released tarball can carry the same
|
|
1477
|
+
* version).
|
|
1478
|
+
*/
|
|
1479
|
+
const install = data.install ?? null
|
|
1480
|
+
const installState = install === null ? '' : String(install.state ?? '')
|
|
1481
|
+
/**
|
|
1482
|
+
* The one state where installing is the row's business: the profile holds an
|
|
1483
|
+
* older PUBLISHED copy, so the action is the reason the console exists.
|
|
1484
|
+
*/
|
|
1485
|
+
const installable = installState === 'update'
|
|
1486
|
+
/**
|
|
1487
|
+
* Installing is possible in three more states, and in none of them does it
|
|
1488
|
+
* belong on the row.
|
|
1489
|
+
*
|
|
1490
|
+
* `checkout` is the normal state on the machine that DEVELOPED the plugin: the
|
|
1491
|
+
* profile points at the working copy, so the row would carry a button whose only
|
|
1492
|
+
* effect is to replace the code being edited with a tarball. The row is for the
|
|
1493
|
+
* work, not for undoing the setup that makes the work possible. `ahead` is worse
|
|
1494
|
+
* than noise — the profile holds something NEWER, so the button offers a
|
|
1495
|
+
* downgrade. `differs` cannot be ordered at all, so it is a shot in the dark.
|
|
1496
|
+
*
|
|
1497
|
+
* They stay in the expansion, as quiet buttons sitting next to the sentence that
|
|
1498
|
+
* says what they would do, because that is still where "does the released
|
|
1499
|
+
* artifact actually install?" gets answered.
|
|
1500
|
+
*/
|
|
1501
|
+
const installSecondary = installState === 'checkout' || installState === 'ahead' || installState === 'differs'
|
|
1502
|
+
const installLabel = installState === 'update' ? 'action.update' : 'action.installRelease'
|
|
1503
|
+
const installExplain = (() => {
|
|
1504
|
+
if (install === null) return null
|
|
1505
|
+
const params = {
|
|
1506
|
+
package: String(install.packageName ?? ''),
|
|
1507
|
+
profile: String(install.profile ?? ''),
|
|
1508
|
+
tag: String(install.latestTag ?? ''),
|
|
1509
|
+
spec: String(install.spec ?? ''),
|
|
1510
|
+
installed: String(install.installedVersion ?? '?'),
|
|
1511
|
+
}
|
|
1512
|
+
if (installState === 'checkout') return t('install.checkout', params)
|
|
1513
|
+
if (installState === 'ahead') return t('install.ahead', params)
|
|
1514
|
+
if (installState === 'differs') return t('install.differs', params)
|
|
1515
|
+
if (installState === 'current') return t('install.current', params)
|
|
1516
|
+
if (installState === 'no-release') return t('install.noRelease', params)
|
|
1517
|
+
return t('install.notInstalled', params)
|
|
1518
|
+
})()
|
|
1519
|
+
/**
|
|
1520
|
+
* What npm holds for this package, and why a push is or is not offered.
|
|
1521
|
+
*
|
|
1522
|
+
* Fetched on demand by the page rather than baked into the overview, so it is
|
|
1523
|
+
* `null` until the first answer arrives — and a `null` renders nothing at all,
|
|
1524
|
+
* which is the honest thing to show when the registry has not been asked.
|
|
1525
|
+
*/
|
|
1526
|
+
const npmInfo = npm ?? null
|
|
1527
|
+
const npmBlockers = Array.isArray(npmInfo?.blockers) ? npmInfo.blockers : []
|
|
1528
|
+
const npmCanPush = npmInfo !== null && npmInfo.canPublish === true
|
|
1529
|
+
const npmExplain = (() => {
|
|
1530
|
+
if (npmInfo === null) return null
|
|
1531
|
+
const params = {
|
|
1532
|
+
version: String(npmInfo.version ?? '?'),
|
|
1533
|
+
latest: String(npmInfo.latest ?? '—'),
|
|
1534
|
+
reason: String(npmInfo.registryProblem ?? npmInfo.state ?? ''),
|
|
1535
|
+
}
|
|
1536
|
+
if (npmInfo.canPublish === true) {
|
|
1537
|
+
if (npmInfo.state === 'unregistered') return t('npm.state.unregistered', params)
|
|
1538
|
+
if (npmInfo.state === 'published') return t('npm.state.published', params)
|
|
1539
|
+
return t('npm.state.unpublished', params)
|
|
1540
|
+
}
|
|
1541
|
+
/* The first blocker is the one to fix first; the rest are consequences. */
|
|
1542
|
+
const first = npmBlockers[0]
|
|
1543
|
+
const key = `npm.blocked.${first ?? 'registry-unreachable'}`
|
|
1544
|
+
const text = t(key, params)
|
|
1545
|
+
return text === key ? t('npm.state.unknown', params) : text
|
|
1546
|
+
})()
|
|
1547
|
+
/**
|
|
1548
|
+
* Whether releasing the local version is possible, decided by the Host so this
|
|
1549
|
+
* panel and the dispatch route cannot disagree. `blocked` is only ever proven
|
|
1550
|
+
* (the version's release belongs to another commit), never guessed.
|
|
1551
|
+
*/
|
|
1552
|
+
const check = data.releaseCheck ?? null
|
|
1553
|
+
const blocked = check !== null && check.state === 'blocked'
|
|
1554
|
+
const state = runState(run)
|
|
1555
|
+
|
|
1556
|
+
/* Only non-zero local signals are shown: a column of "clean" chips is noise,
|
|
1557
|
+
and the row exists to make the exceptions visible. */
|
|
1558
|
+
const chips = []
|
|
1559
|
+
/* A version that is already taken makes 发布 impossible, so the row says so
|
|
1560
|
+
before the button is pressed rather than after a run has failed. */
|
|
1561
|
+
if (blocked) chips.push(h(Chip, { key: 'v', state: 'warn', title: t('release.takenTitle') }, t('chip.versionTaken')))
|
|
1562
|
+
/* An installed copy that is genuinely behind the release is the one update
|
|
1563
|
+
worth a chip; a `link:` checkout is the normal state here and saying so on
|
|
1564
|
+
every row would be a column of noise. */
|
|
1565
|
+
if (installState === 'update' && typeof install?.latestTag === 'string') {
|
|
1566
|
+
chips.push(h(Chip, { key: 'u', state: 'warn', title: installExplain ?? undefined }, t('chip.update', { tag: install.latestTag })))
|
|
1567
|
+
}
|
|
1568
|
+
/*
|
|
1569
|
+
* The npm chip is the whole row-level surface for the second channel: a version
|
|
1570
|
+
* that is on npm is a fact worth seeing, and a version that is not is a job
|
|
1571
|
+
* waiting. Anything else — private, dirty, not signed in — belongs in the
|
|
1572
|
+
* expansion, where there is room to say what to do about it.
|
|
1573
|
+
*/
|
|
1574
|
+
if (npmInfo !== null && npmInfo.state === 'published') {
|
|
1575
|
+
chips.push(h(Chip, { key: 'n', state: 'success', title: String(npmInfo.pageUrl ?? '') }, t('npm.chipPublished', { version: String(npmInfo.version ?? '') })))
|
|
1576
|
+
} else if (npmCanPush) {
|
|
1577
|
+
chips.push(h(Chip, { key: 'n', state: 'warn', title: npmExplain ?? undefined }, t('npm.chipPending', { version: String(npmInfo.version ?? '') })))
|
|
1578
|
+
}
|
|
1579
|
+
/*
|
|
1580
|
+
* A published release whose update note has not gone out yet. It is a chip
|
|
1581
|
+
* rather than a sentence because the row is scanned: the answer to "why has
|
|
1582
|
+
* this not been announced" belongs behind the expansion, next to the button.
|
|
1583
|
+
*/
|
|
1584
|
+
if (bili?.binding != null && typeof data.publishedTag === 'string' && data.publishedTag !== '') {
|
|
1585
|
+
const announced = Array.isArray(bili.announced) ? bili.announced : []
|
|
1586
|
+
const baselineTag = bili.baseline === null || bili.baseline === undefined ? null : bili.baseline.tag
|
|
1587
|
+
const covered = announced.some((entry) => entry.tag === data.publishedTag) || baselineTag === data.publishedTag || (bili.baseline !== null && baselineTag === null)
|
|
1588
|
+
if (!covered) chips.push(h(Chip, { key: 'bl', state: 'warn' }, t('bili.chip.pending', { tag: data.publishedTag })))
|
|
1589
|
+
}
|
|
1590
|
+
if (local.available === true) {
|
|
1591
|
+
if (Number.isFinite(local.dirty) && local.dirty > 0) chips.push(h(Chip, { key: 'd', state: 'warn' }, t('chip.dirty', { count: String(local.dirty) })))
|
|
1592
|
+
if (Number.isFinite(local.ahead) && local.ahead > 0) chips.push(h(Chip, { key: 'a', state: 'warn' }, `↑${local.ahead}`))
|
|
1593
|
+
if (Number.isFinite(local.behind) && local.behind > 0) chips.push(h(Chip, { key: 'b', state: 'idle' }, `↓${local.behind}`))
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
return h(
|
|
1597
|
+
'div',
|
|
1598
|
+
{ className: 'dsc-item' },
|
|
1599
|
+
h(
|
|
1600
|
+
'div',
|
|
1601
|
+
{ className: 'dsc-row' },
|
|
1602
|
+
h('span', { className: 'dsc-dot', 'data-state': state, title: run === null ? t('state.noRuns') : `${run.workflow} · ${run.conclusion || run.status}` }),
|
|
1603
|
+
h('span', { className: 'dsc-name' }, data.label),
|
|
1604
|
+
/* Registered, but GitHub has not been read yet: say so rather than
|
|
1605
|
+
showing "未发布", which would be a claim the panel cannot make. */
|
|
1606
|
+
data.pending === true
|
|
1607
|
+
? h(Chip, { state: 'busy' }, t('state.reading'))
|
|
1608
|
+
: data.versionKnown === true && data.version !== null ? h(Chip, { state: 'idle' }, `v${data.version}`) : null,
|
|
1609
|
+
data.pending === true
|
|
1610
|
+
? null
|
|
1611
|
+
: data.draftTag !== null
|
|
1612
|
+
? h(Chip, { state: 'warn' }, `${t('chip.draft')} ${data.draftTag}`)
|
|
1613
|
+
: data.published
|
|
1614
|
+
// Name the tag: without a local checkout the panel knows the release
|
|
1615
|
+
// from GitHub alone, and the tag is the fact it actually has.
|
|
1616
|
+
? h(Chip, { state: 'success' }, data.publishedTag === null ? t('chip.published') : `${t('chip.published')} ${data.publishedTag}`)
|
|
1617
|
+
: h(Chip, { state: 'idle' }, t('chip.unpublished')),
|
|
1618
|
+
chips,
|
|
1619
|
+
h('span', { className: 'dsc-grow' }, data.pending === true ? t('state.reading') : run === null ? t('state.noRuns') : `${run.workflow} · ${duration(run)} · ${stamp(run.createdAt)}`),
|
|
1620
|
+
h(
|
|
1621
|
+
'div',
|
|
1622
|
+
{ className: 'dsc-right' },
|
|
1623
|
+
/* 提交, 构建, 发布 — the order the work happens in. 提交 first because the
|
|
1624
|
+
other two act on what is on GitHub: a release builds the pushed commit, and
|
|
1625
|
+
a build runs the pushed commit, so neither can see work that is still only
|
|
1626
|
+
on this disk. It is never primary: it is the step, not the goal. */
|
|
1627
|
+
local.available === true
|
|
1628
|
+
? h(Btn, {
|
|
1629
|
+
disabled: busy !== '' || (!canCommit && !canPush),
|
|
1630
|
+
title: commitExplain,
|
|
1631
|
+
onClick: () => {
|
|
1632
|
+
setOpen(true)
|
|
1633
|
+
setCommitting(true)
|
|
1634
|
+
},
|
|
1635
|
+
}, busy === `commit:${data.repo}` ? '…' : t(commitLabel))
|
|
1636
|
+
: null,
|
|
1637
|
+
data.hasBuildWorkflow ? h(Btn, { disabled: busy !== '', title: t('action.build'), onClick: () => onAction('build', data) }, busy === `build:${data.repo}` ? '…' : t('action.build')) : null,
|
|
1638
|
+
data.hasReleaseWorkflow
|
|
1639
|
+
? blocked && typeof check.nextTag === 'string' && check.nextTag !== ''
|
|
1640
|
+
/* The version is the obstacle, so the button that works is the one
|
|
1641
|
+
that removes it — refusing to dispatch and leaving the user to edit
|
|
1642
|
+
package.json by hand would be the same dead end, one click later. */
|
|
1643
|
+
? h(Btn, {
|
|
1644
|
+
kind: 'primary',
|
|
1645
|
+
disabled: busy !== '',
|
|
1646
|
+
title: t('action.bumpRelease'),
|
|
1647
|
+
onClick: () => {
|
|
1648
|
+
setOpen(true)
|
|
1649
|
+
setBumping(true)
|
|
1650
|
+
},
|
|
1651
|
+
}, busy === `bump:${data.repo}` ? '…' : t('action.bumpRelease'))
|
|
1652
|
+
: h(Btn, { kind: 'primary', disabled: busy !== '', title: t('action.release'), onClick: () => onAction('release', data) }, busy === `release:${data.repo}` ? '…' : t('action.release'))
|
|
1653
|
+
: null,
|
|
1654
|
+
/* The other half of the loop the console exists for: 发布 cuts the
|
|
1655
|
+
release, this one installs it into the profile that is running. */
|
|
1656
|
+
installable && install?.latestTag
|
|
1657
|
+
? h(Btn, {
|
|
1658
|
+
disabled: busy !== '',
|
|
1659
|
+
title: installExplain ?? undefined,
|
|
1660
|
+
onClick: () => {
|
|
1661
|
+
setOpen(true)
|
|
1662
|
+
setUpdating(true)
|
|
1663
|
+
},
|
|
1664
|
+
}, busy === `update:${data.repo}` ? '…' : t(installLabel, { tag: String(install.latestTag) }))
|
|
1665
|
+
: null,
|
|
1666
|
+
h(Btn, { kind: 'quiet', square: true, onClick: () => setOpen((value) => !value), title: open ? t('action.collapse') : t('action.expand') }, open ? '▴' : '▾'),
|
|
1667
|
+
),
|
|
1668
|
+
),
|
|
1669
|
+
open
|
|
1670
|
+
? h(
|
|
1671
|
+
'div',
|
|
1672
|
+
{ className: 'dsc-detail' },
|
|
1673
|
+
/* No usable working tree: say where one would go and offer to fetch it.
|
|
1674
|
+
Everything else on this page — 提交, 构建, 发布, the dirty chip — acts on
|
|
1675
|
+
a checkout, so this is the step that makes the rest of the row real. */
|
|
1676
|
+
local.available === true
|
|
1677
|
+
? h('span', { className: 'dsc-mono' }, `${t('meta.path')} ${data.localPath}`)
|
|
1678
|
+
: h(CloneCheckout, {
|
|
1679
|
+
t,
|
|
1680
|
+
data,
|
|
1681
|
+
local,
|
|
1682
|
+
busy,
|
|
1683
|
+
onClone: (repo, url) => { void onClone(repo, url) },
|
|
1684
|
+
}),
|
|
1685
|
+
data.problems.length > 0 ? h('div', { className: 'dsc-warn' }, data.problems.join(' · ')) : null,
|
|
1686
|
+
|
|
1687
|
+
/* Why 发布 cannot work, and what would make it work. Both notes are
|
|
1688
|
+
about the same trap: a release builds the pushed commit, so anything
|
|
1689
|
+
only in the working tree or only local is silently absent from it. */
|
|
1690
|
+
blocked
|
|
1691
|
+
? h('div', { className: 'dsc-warn' }, check.nextTag === null
|
|
1692
|
+
? t('release.takenNoNext', { tag: check.tag ?? '', owner: shortSha(check.owner), built: shortSha(check.built) })
|
|
1693
|
+
: t('release.taken', { tag: check.tag ?? '', owner: shortSha(check.owner), built: shortSha(check.built), next: check.nextTag }))
|
|
1694
|
+
: null,
|
|
1695
|
+
local.available === true && Number.isFinite(local.dirty) && local.dirty > 0
|
|
1696
|
+
? h('div', { className: 'dsc-warn' }, t('release.dirtyNote', { count: String(local.dirty) }))
|
|
1697
|
+
: null,
|
|
1698
|
+
local.available === true && Number.isFinite(local.ahead) && local.ahead > 0
|
|
1699
|
+
? h('div', { className: 'dsc-warn' }, t('release.aheadNote', { count: String(local.ahead) }))
|
|
1700
|
+
: null,
|
|
1701
|
+
bumping && blocked && typeof check.nextTag === 'string'
|
|
1702
|
+
? h(
|
|
1703
|
+
'div',
|
|
1704
|
+
{ className: 'dsc-line' },
|
|
1705
|
+
h('span', { className: 'dsc-grow' }, t('confirm.bumpRelease', {
|
|
1706
|
+
from: data.version ?? '?',
|
|
1707
|
+
to: check.nextTag,
|
|
1708
|
+
branch: typeof local.branch === 'string' && local.branch !== '' ? local.branch : 'main',
|
|
1709
|
+
})),
|
|
1710
|
+
h(Btn, {
|
|
1711
|
+
kind: 'danger',
|
|
1712
|
+
disabled: busy !== '',
|
|
1713
|
+
onClick: () => {
|
|
1714
|
+
setBumping(false)
|
|
1715
|
+
onBump(data)
|
|
1716
|
+
},
|
|
1717
|
+
}, t('action.bumpRelease')),
|
|
1718
|
+
h(Btn, { kind: 'quiet', onClick: () => setBumping(false) }, t('confirm.cancel')),
|
|
1719
|
+
)
|
|
1720
|
+
: null,
|
|
1721
|
+
/*
|
|
1722
|
+
* 提交 asks first, and asks for a message — it writes a commit on this disk
|
|
1723
|
+
* and pushes it. `git add -A` is the one action in this panel that can
|
|
1724
|
+
* sweep in a file the author never meant to publish, so the list is on
|
|
1725
|
+
* screen above the button: the count alone ("7 changes") could be the three
|
|
1726
|
+
* you meant and four you have never seen.
|
|
1727
|
+
*/
|
|
1728
|
+
committing && (canCommit || canPush)
|
|
1729
|
+
? h(
|
|
1730
|
+
'div',
|
|
1731
|
+
{ className: 'dsc-commit' },
|
|
1732
|
+
canCommit ? h('span', null, t('commit.willCommit', { count: String(dirty) })) : null,
|
|
1733
|
+
canCommit && Array.isArray(local.files) && local.files.length > 0
|
|
1734
|
+
? h('span', { className: 'dsc-mono dsc-quiet' }, `${local.files.join(' · ')}${dirty > local.files.length ? ` …+${String(dirty - local.files.length)}` : ''}`)
|
|
1735
|
+
: null,
|
|
1736
|
+
canCommit
|
|
1737
|
+
? h('input', {
|
|
1738
|
+
className: 'dsc-input',
|
|
1739
|
+
type: 'text',
|
|
1740
|
+
value: commitMessage,
|
|
1741
|
+
placeholder: t('commit.messagePlaceholder'),
|
|
1742
|
+
onChange: (event) => setCommitMessage(String(event?.target?.value ?? '')),
|
|
1743
|
+
})
|
|
1744
|
+
: h('span', null, t('confirm.push', { count: String(ahead), branch: branchName })),
|
|
1745
|
+
h(
|
|
1746
|
+
'div',
|
|
1747
|
+
{ className: 'dsc-line' },
|
|
1748
|
+
h(Btn, {
|
|
1749
|
+
kind: 'danger',
|
|
1750
|
+
disabled: busy !== '' || (canCommit && commitMessage.trim() === ''),
|
|
1751
|
+
title: canCommit && commitMessage.trim() === '' ? t('commit.messageRequired') : undefined,
|
|
1752
|
+
onClick: () => {
|
|
1753
|
+
const message = canCommit ? commitMessage.trim() : ''
|
|
1754
|
+
setCommitting(false)
|
|
1755
|
+
setCommitMessage('')
|
|
1756
|
+
onCommit(data, message)
|
|
1757
|
+
},
|
|
1758
|
+
}, t(canCommit ? 'action.commitConfirm' : 'action.pushConfirm')),
|
|
1759
|
+
h(Btn, { kind: 'quiet', onClick: () => setCommitting(false) }, t('confirm.cancel')),
|
|
1760
|
+
),
|
|
1761
|
+
)
|
|
1762
|
+
: null,
|
|
1763
|
+
|
|
1764
|
+
/* What this profile has installed, and the one action that changes it.
|
|
1765
|
+
Without this the panel could cut a release and never take it: the
|
|
1766
|
+
installed copy is the half of "is it published?" that GitHub cannot
|
|
1767
|
+
answer. */
|
|
1768
|
+
install !== null
|
|
1769
|
+
? h(
|
|
1770
|
+
'div',
|
|
1771
|
+
{ className: 'dsc-sub' },
|
|
1772
|
+
h('span', null, t('install.title')),
|
|
1773
|
+
h(
|
|
1774
|
+
'div',
|
|
1775
|
+
{ className: 'dsc-line' },
|
|
1776
|
+
h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(install.packageName ?? '')),
|
|
1777
|
+
/* No version chip when none could be read: the explanation
|
|
1778
|
+
below says which copy this is, and an invented "v?" would
|
|
1779
|
+
be a claim the Host did not make. */
|
|
1780
|
+
install.installedVersion === null
|
|
1781
|
+
? null
|
|
1782
|
+
: h(Chip, { state: installState === 'current' ? 'success' : installState === 'update' ? 'warn' : 'idle' }, `v${String(install.installedVersion)}`),
|
|
1783
|
+
installState === 'checkout' ? h(Chip, { state: 'warn' }, t('chip.checkout')) : null,
|
|
1784
|
+
h('span', { className: 'dsc-grow', title: String(install.spec ?? '') }, install.present === true ? String(install.spec ?? '') : `— ${String(install.profile ?? '')}`),
|
|
1785
|
+
(installable || installSecondary) && install.latestTag
|
|
1786
|
+
? h(Btn, {
|
|
1787
|
+
kind: installState === 'update' ? 'primary' : 'quiet',
|
|
1788
|
+
disabled: busy !== '',
|
|
1789
|
+
title: installExplain ?? undefined,
|
|
1790
|
+
onClick: () => setUpdating(true),
|
|
1791
|
+
}, busy === `update:${data.repo}` ? '…' : t(installLabel, { tag: String(install.latestTag) }))
|
|
1792
|
+
: null,
|
|
1793
|
+
),
|
|
1794
|
+
h('span', null, installExplain),
|
|
1795
|
+
)
|
|
1796
|
+
: null,
|
|
1797
|
+
/* The second channel. A GitHub Release and an npm version are separate
|
|
1798
|
+
facts about the same package, and this is where they sit next to each
|
|
1799
|
+
other instead of in two browser tabs. */
|
|
1800
|
+
npmInfo !== null
|
|
1801
|
+
? h(
|
|
1802
|
+
'div',
|
|
1803
|
+
{ className: 'dsc-sub' },
|
|
1804
|
+
h('span', null, t('npm.title')),
|
|
1805
|
+
h(
|
|
1806
|
+
'div',
|
|
1807
|
+
{ className: 'dsc-line' },
|
|
1808
|
+
h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(npmInfo.packageName ?? '')),
|
|
1809
|
+
npmInfo.state === 'published'
|
|
1810
|
+
? h(Chip, { state: 'success' }, `v${String(npmInfo.version ?? '?')}`)
|
|
1811
|
+
: npmInfo.state === 'unregistered'
|
|
1812
|
+
? h(Chip, { state: 'warn' }, t('npm.chipUnregistered'))
|
|
1813
|
+
: h(Chip, { state: 'idle' }, `v${String(npmInfo.version ?? '?')}`),
|
|
1814
|
+
npmInfo.latest !== null && npmInfo.latest !== undefined
|
|
1815
|
+
? h('span', { className: 'dsc-grow' }, `npm latest ${String(npmInfo.latest)}`)
|
|
1816
|
+
: h('span', { className: 'dsc-grow' }, String(npmInfo.pageUrl ?? '')),
|
|
1817
|
+
npmCanPush
|
|
1818
|
+
? h(Btn, {
|
|
1819
|
+
disabled: busy !== '',
|
|
1820
|
+
title: npmExplain ?? undefined,
|
|
1821
|
+
onClick: () => setPublishing(true),
|
|
1822
|
+
}, busy === `npm:${data.repo}` ? '…' : t('npm.action.publish', { version: String(npmInfo.version ?? '') }))
|
|
1823
|
+
: null,
|
|
1824
|
+
),
|
|
1825
|
+
h('span', null, npmExplain),
|
|
1826
|
+
/* A draft (or missing) GitHub release on the same version is worth
|
|
1827
|
+
saying out loud: the two channels are about to disagree in public. */
|
|
1828
|
+
npmCanPush && data.publishedTag !== `v${String(npmInfo.version ?? '')}`
|
|
1829
|
+
? h('span', { className: 'dsc-warn' }, t('npm.githubDraft'))
|
|
1830
|
+
: null,
|
|
1831
|
+
)
|
|
1832
|
+
: null,
|
|
1833
|
+
publishing && npmCanPush
|
|
1834
|
+
? h(
|
|
1835
|
+
'div',
|
|
1836
|
+
{ className: 'dsc-line' },
|
|
1837
|
+
h('span', { className: 'dsc-grow' }, t('confirm.npmPublish', {
|
|
1838
|
+
package: String(npmInfo.packageName ?? ''),
|
|
1839
|
+
version: String(npmInfo.version ?? ''),
|
|
1840
|
+
registry: String(npmInfo.registry ?? ''),
|
|
1841
|
+
})),
|
|
1842
|
+
h('input', {
|
|
1843
|
+
className: 'dsc-input dsc-otp',
|
|
1844
|
+
type: 'text',
|
|
1845
|
+
inputMode: 'numeric',
|
|
1846
|
+
autoComplete: 'one-time-code',
|
|
1847
|
+
placeholder: t('npm.otpPlaceholder'),
|
|
1848
|
+
title: t('npm.otpHint'),
|
|
1849
|
+
'aria-label': t('npm.otpLabel'),
|
|
1850
|
+
value: otp,
|
|
1851
|
+
onChange: (event) => setOtp(String(event?.target?.value ?? '').replace(/[^0-9]/g, '').slice(0, 8)),
|
|
1852
|
+
}),
|
|
1853
|
+
h(Btn, {
|
|
1854
|
+
kind: 'danger',
|
|
1855
|
+
disabled: busy !== '',
|
|
1856
|
+
onClick: () => {
|
|
1857
|
+
setPublishing(false)
|
|
1858
|
+
onNpmPublish(data, otp)
|
|
1859
|
+
},
|
|
1860
|
+
}, t('npm.action.confirm')),
|
|
1861
|
+
h(Btn, { kind: 'quiet', onClick: () => setPublishing(false) }, t('confirm.cancel')),
|
|
1862
|
+
)
|
|
1863
|
+
: null,
|
|
1864
|
+
|
|
1865
|
+
/* The confirmation has to appear for every button that can be pressed,
|
|
1866
|
+
including the quiet ones — a secondary action whose confirm dialog was
|
|
1867
|
+
gated on the row's narrower condition would be a dead button. */
|
|
1868
|
+
updating && (installable || installSecondary) && install?.latestTag
|
|
1869
|
+
? h(
|
|
1870
|
+
'div',
|
|
1871
|
+
{ className: 'dsc-line' },
|
|
1872
|
+
h('span', { className: 'dsc-grow' }, t('confirm.update', {
|
|
1873
|
+
package: String(install.packageName ?? ''),
|
|
1874
|
+
profile: String(install.profile ?? ''),
|
|
1875
|
+
spec: install.present === true ? String(install.spec ?? '') : '—',
|
|
1876
|
+
tag: String(install.latestTag),
|
|
1877
|
+
})),
|
|
1878
|
+
h(Btn, {
|
|
1879
|
+
kind: 'danger',
|
|
1880
|
+
disabled: busy !== '',
|
|
1881
|
+
onClick: () => {
|
|
1882
|
+
setUpdating(false)
|
|
1883
|
+
onUpdate(data)
|
|
1884
|
+
},
|
|
1885
|
+
}, t('action.confirmUpdate')),
|
|
1886
|
+
h(Btn, { kind: 'quiet', onClick: () => setUpdating(false) }, t('confirm.cancel')),
|
|
1887
|
+
)
|
|
1888
|
+
: null,
|
|
1889
|
+
|
|
1890
|
+
/* The third channel, inside the row that owns it: which video carries
|
|
1891
|
+
this repository's update notes, and the button that posts one now. */
|
|
1892
|
+
bili !== null && bili !== undefined
|
|
1893
|
+
? h(BiliBinding, { t, data, bili, busy, onBind: onBiliBind, onPreview: onBiliPreview, onAnnounce: onBiliAnnounce })
|
|
1894
|
+
: null,
|
|
1895
|
+
|
|
1896
|
+
h(
|
|
1897
|
+
'div',
|
|
1898
|
+
{ className: 'dsc-sub' },
|
|
1899
|
+
h('span', null, `${t('meta.version')} ${data.version ?? '?'}${data.expectedTag === null ? '' : ` · ${t('meta.expectedTag', { tag: data.expectedTag })}`}`),
|
|
1900
|
+
data.releases.length === 0
|
|
1901
|
+
? h('span', null, t('state.noReleases'))
|
|
1902
|
+
: data.releases.map((release) =>
|
|
1903
|
+
h(
|
|
1904
|
+
'div',
|
|
1905
|
+
{ className: 'dsc-line', key: release.tag },
|
|
1906
|
+
h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, release.tag),
|
|
1907
|
+
release.draft ? h(Chip, { state: 'warn' }, t('chip.draft')) : h(Chip, { state: 'success' }, t('chip.published')),
|
|
1908
|
+
h('span', { className: 'dsc-grow' }, `${stamp(release.createdAt)} · ${t('meta.assets', { count: String(release.assets.length) })}`),
|
|
1909
|
+
release.draft
|
|
1910
|
+
? confirming === release.tag
|
|
1911
|
+
? h(
|
|
1912
|
+
React.Fragment,
|
|
1913
|
+
null,
|
|
1914
|
+
h(Btn, { kind: 'danger', disabled: busy !== '', onClick: () => onPublish(release.tag) }, t('action.confirmPublish')),
|
|
1915
|
+
h(Btn, { kind: 'quiet', onClick: () => setConfirming(null) }, t('confirm.cancel')),
|
|
1916
|
+
)
|
|
1917
|
+
: h(Btn, { onClick: () => setConfirming(release.tag) }, t('action.publishDraft'))
|
|
1918
|
+
: null,
|
|
1919
|
+
release.url !== '' ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(release.url, '_blank', 'noopener,noreferrer') }, t('action.open')) : null,
|
|
1920
|
+
),
|
|
1921
|
+
),
|
|
1922
|
+
),
|
|
1923
|
+
|
|
1924
|
+
h(
|
|
1925
|
+
'div',
|
|
1926
|
+
{ className: 'dsc-sub' },
|
|
1927
|
+
h('span', null, t('label.runs')),
|
|
1928
|
+
data.runs.length === 0
|
|
1929
|
+
? h('span', null, t('state.noRuns'))
|
|
1930
|
+
: data.runs.slice(0, 4).map((entry) =>
|
|
1931
|
+
h(
|
|
1932
|
+
'div',
|
|
1933
|
+
{ className: 'dsc-line', key: String(entry.id) },
|
|
1934
|
+
h(Chip, { state: runState(entry) }, entry.conclusion || entry.status || t('run.unknown')),
|
|
1935
|
+
h('span', { className: 'dsc-grow', title: entry.title }, `${entry.workflow} · ${entry.title}`),
|
|
1936
|
+
h('span', null, duration(entry)),
|
|
1937
|
+
entry.status !== 'completed' && entry.id !== null
|
|
1938
|
+
? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onAction('cancel', data, { runId: entry.id }) }, t('action.cancel'))
|
|
1939
|
+
: null,
|
|
1940
|
+
entry.status === 'completed' && entry.id !== null
|
|
1941
|
+
? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onAction('rerun-failed', data, { runId: entry.id }) }, t('action.rerun'))
|
|
1942
|
+
: null,
|
|
1943
|
+
entry.status === 'completed' && entry.id !== null
|
|
1944
|
+
? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onLogs(data, entry) }, logs !== null && logs.runId === entry.id ? t('action.hideLogs') : t('action.logs'))
|
|
1945
|
+
: null,
|
|
1946
|
+
),
|
|
1947
|
+
),
|
|
1948
|
+
),
|
|
1949
|
+
|
|
1950
|
+
logs !== null && logs.repo === data.repo
|
|
1951
|
+
? h(
|
|
1952
|
+
'div',
|
|
1953
|
+
null,
|
|
1954
|
+
h('pre', { className: 'dsc-logs' }, logs.lines.join('\n')),
|
|
1955
|
+
logs.truncated ? h('span', null, t('state.truncated', { lines: String(logs.lines.length) })) : null,
|
|
1956
|
+
)
|
|
1957
|
+
: null,
|
|
1958
|
+
)
|
|
1959
|
+
: null,
|
|
1960
|
+
)
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** Shared data plumbing for the panel and the settings page. */
|
|
1964
|
+
function useConsoleState() {
|
|
1965
|
+
const [status, setStatus] = React.useState(null)
|
|
1966
|
+
const [overview, setOverview] = React.useState(null)
|
|
1967
|
+
const [npm, setNpm] = React.useState(null)
|
|
1968
|
+
const [bili, setBili] = React.useState(null)
|
|
1969
|
+
const [error, setError] = React.useState(null)
|
|
1970
|
+
|
|
1971
|
+
const loadStatus = React.useCallback(async () => {
|
|
1972
|
+
const result = await postJson('/status', {}, STATUS_TIMEOUT_MS)
|
|
1973
|
+
if (!result.ok) {
|
|
1974
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
1975
|
+
return null
|
|
1976
|
+
}
|
|
1977
|
+
if (result.response.ok && result.payload?.ok === true) {
|
|
1978
|
+
setStatus(result.payload.value ?? null)
|
|
1979
|
+
setError(null)
|
|
1980
|
+
return result.payload.value
|
|
1981
|
+
}
|
|
1982
|
+
setError(isMissingRoute(result.response.status) ? 'stale-host' : (result.payload?.message ?? `HTTP ${String(result.response.status)}`))
|
|
1983
|
+
return null
|
|
1984
|
+
}, [])
|
|
1985
|
+
|
|
1986
|
+
/**
|
|
1987
|
+
* `silent` is what the poll uses: a failed BACKGROUND request must never
|
|
1988
|
+
* clear the last known state nor raise a banner the user did not ask for.
|
|
1989
|
+
*/
|
|
1990
|
+
const loadOverview = React.useCallback(async (options) => {
|
|
1991
|
+
const result = await postJson('/overview', { force: options?.force === true }, OVERVIEW_TIMEOUT_MS)
|
|
1992
|
+
if (!result.ok) {
|
|
1993
|
+
if (options?.silent !== true) setError(result.aborted ? 'timeout' : 'host')
|
|
1994
|
+
return
|
|
1995
|
+
}
|
|
1996
|
+
if (result.response.ok && result.payload?.ok === true) {
|
|
1997
|
+
setOverview(result.payload.value ?? null)
|
|
1998
|
+
if (options?.silent !== true) setError(null)
|
|
1999
|
+
}
|
|
2000
|
+
}, [])
|
|
2001
|
+
|
|
2002
|
+
/**
|
|
2003
|
+
* The npm registry's view of every configured package.
|
|
2004
|
+
*
|
|
2005
|
+
* Deliberately NOT part of the overview. The overview is polled every thirty
|
|
2006
|
+
* seconds and answers from GitHub; this costs one HTTPS request per repository
|
|
2007
|
+
* plus a `whoami`, and its answer changes when someone publishes — so it is
|
|
2008
|
+
* fetched when the panel opens, after a publish, and when asked.
|
|
2009
|
+
*
|
|
2010
|
+
* A failure is reported as "no npm information", never as an empty one: a chip
|
|
2011
|
+
* that says "not on npm" because the registry could not be reached would be a
|
|
2012
|
+
* statement the Host never made.
|
|
2013
|
+
*/
|
|
2014
|
+
const loadNpm = React.useCallback(async (options) => {
|
|
2015
|
+
const result = await postJson('/npm-status', { force: options?.force === true }, NPM_TIMEOUT_MS)
|
|
2016
|
+
if (!result.ok || !result.response.ok || result.payload?.ok !== true) return
|
|
2017
|
+
setNpm(result.payload.value ?? null)
|
|
2018
|
+
}, [])
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* The Bilibili side: which credential, which video, and what was said.
|
|
2022
|
+
*
|
|
2023
|
+
* Also not polled, and for a second reason on top of npm's: the answer
|
|
2024
|
+
* includes a video lookup and an account lookup, and the panel asks for it
|
|
2025
|
+
* when it opens, after an action, and when a person asks. The Host caches
|
|
2026
|
+
* both answers (`bilibiliVerifyTtlMs`), so even a refresh loop is bounded.
|
|
2027
|
+
*/
|
|
2028
|
+
const loadBili = React.useCallback(async (options) => {
|
|
2029
|
+
const result = await postJson('/bilibili-status', { force: options?.force === true }, OVERVIEW_TIMEOUT_MS)
|
|
2030
|
+
if (!result.ok || !result.response.ok || result.payload?.ok !== true) return
|
|
2031
|
+
setBili(result.payload.value ?? null)
|
|
2032
|
+
}, [])
|
|
2033
|
+
|
|
2034
|
+
return { status, overview, npm, bili, error, setError, loadStatus, loadOverview, loadNpm, loadBili }
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/** Human-readable text for the transport and version failures. */
|
|
2038
|
+
function errorText(t, code) {
|
|
2039
|
+
if (code === 'timeout') return t('state.timeout')
|
|
2040
|
+
if (code === 'host') return t('state.hostGone')
|
|
2041
|
+
if (code === 'stale-host') return t('stale.text')
|
|
2042
|
+
return code
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Whether a response means "this Host half does not have that route".
|
|
2047
|
+
*
|
|
2048
|
+
* The local web server answers an unmounted `/api/*` path with 401; a Host
|
|
2049
|
+
* that mounts a different set can also 404. Both are version mismatches first
|
|
2050
|
+
* and request failures second, so they get the version message.
|
|
2051
|
+
*/
|
|
2052
|
+
function isMissingRoute(status) {
|
|
2053
|
+
return status === 401 || status === 404
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/** Whether a sign-in or a scope grant is still required. */
|
|
2057
|
+
function needsAccountSetup(gh) {
|
|
2058
|
+
if (gh.available !== true) return true
|
|
2059
|
+
if (gh.authenticated !== true) return true
|
|
2060
|
+
return Array.isArray(gh.missingScopes) && gh.missingScopes.length > 0
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/** The console panel: compact rows, and the picker behind one button. */
|
|
2064
|
+
function ConsolePage(props) {
|
|
2065
|
+
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
2066
|
+
const { status, overview, npm, bili, error, setError, loadStatus, loadOverview, loadNpm, loadBili } = useConsoleState()
|
|
2067
|
+
const [busy, setBusy] = React.useState('')
|
|
2068
|
+
const [notice, setNotice] = React.useState(null)
|
|
2069
|
+
const [logs, setLogs] = React.useState(null)
|
|
2070
|
+
/**
|
|
2071
|
+
* The npm token being typed, held only until the request that writes it.
|
|
2072
|
+
*
|
|
2073
|
+
* It lives in page state rather than anywhere durable on purpose: the Host
|
|
2074
|
+
* writes it to the user-level `.npmrc` that npm itself reads, and clears it from
|
|
2075
|
+
* the field the moment that answers.
|
|
2076
|
+
*/
|
|
2077
|
+
const [npmToken, setNpmToken] = React.useState('')
|
|
2078
|
+
/** Set when a pasted token was refused, which is when the checklist is read. */
|
|
2079
|
+
const [npmTokenRejected, setNpmTokenRejected] = React.useState(false)
|
|
2080
|
+
/** The classified outcome of the last publish: `otp-required`, `E403`, … */
|
|
2081
|
+
const [npmFailure, setNpmFailure] = React.useState(null)
|
|
2082
|
+
/**
|
|
2083
|
+
* The update that just landed and the restart it needs.
|
|
2084
|
+
*
|
|
2085
|
+
* Held as state rather than folded into the notice, because a notice is a
|
|
2086
|
+
* sentence that expires and this is a question with two buttons. It is also
|
|
2087
|
+
* the one thing here that cannot be discovered later: an updated plugin looks
|
|
2088
|
+
* identical until the app restarts, so "you are not running the new code yet"
|
|
2089
|
+
* has to be on screen while the user is still looking.
|
|
2090
|
+
*/
|
|
2091
|
+
const [restartPrompt, setRestartPrompt] = React.useState(null)
|
|
2092
|
+
/**
|
|
2093
|
+
* Which view this panel opens in.
|
|
2094
|
+
*
|
|
2095
|
+
* A first visit is about setting the tool up, so it opens on the repository
|
|
2096
|
+
* list; afterwards the last choice wins. Without this, re-entering the panel
|
|
2097
|
+
* dropped back to the setup view every time — even for someone who had just
|
|
2098
|
+
* finished configuring it and wanted to see the repositories.
|
|
2099
|
+
*/
|
|
2100
|
+
const [managing, setManaging] = React.useState(() => {
|
|
2101
|
+
try {
|
|
2102
|
+
const stored = globalThis.localStorage?.getItem('dsh-cicd.managing')
|
|
2103
|
+
if (stored === 'true') return true
|
|
2104
|
+
if (stored === 'false') return false
|
|
2105
|
+
} catch {
|
|
2106
|
+
/* storage can be unavailable; the default below is fine */
|
|
2107
|
+
}
|
|
2108
|
+
return true
|
|
2109
|
+
})
|
|
2110
|
+
React.useEffect(() => {
|
|
2111
|
+
try {
|
|
2112
|
+
globalThis.localStorage?.setItem('dsh-cicd.managing', String(managing))
|
|
2113
|
+
} catch {
|
|
2114
|
+
/* a preference that cannot be stored is not worth failing over */
|
|
2115
|
+
}
|
|
2116
|
+
}, [managing])
|
|
2117
|
+
|
|
2118
|
+
React.useEffect(() => {
|
|
2119
|
+
void loadStatus()
|
|
2120
|
+
void loadOverview({ force: true })
|
|
2121
|
+
// Once, not on the poll: the npm answer changes when someone publishes.
|
|
2122
|
+
void loadNpm({})
|
|
2123
|
+
// And likewise: a Bilibili answer includes an account and a video lookup.
|
|
2124
|
+
void loadBili({})
|
|
2125
|
+
}, [loadStatus, loadOverview, loadNpm, loadBili])
|
|
2126
|
+
|
|
2127
|
+
const pollSeconds = status?.config?.pollSeconds ?? 30
|
|
2128
|
+
React.useEffect(() => {
|
|
2129
|
+
const timer = globalThis.setInterval(() => {
|
|
2130
|
+
void loadOverview({ silent: true })
|
|
2131
|
+
}, Math.max(10, pollSeconds) * 1000)
|
|
2132
|
+
return () => {
|
|
2133
|
+
globalThis.clearInterval(timer)
|
|
2134
|
+
}
|
|
2135
|
+
}, [loadOverview, pollSeconds])
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Refresh when the panel comes back into view.
|
|
2139
|
+
*
|
|
2140
|
+
* This panel can stay mounted while another one is selected, so re-entering it
|
|
2141
|
+
* used to show whatever had last been rendered — up to a full poll interval
|
|
2142
|
+
* old, which reads as "it still shows the old screen".
|
|
2143
|
+
*/
|
|
2144
|
+
React.useEffect(() => {
|
|
2145
|
+
const onVisible = () => {
|
|
2146
|
+
if (document.visibilityState === 'visible') void loadOverview({ force: true })
|
|
2147
|
+
}
|
|
2148
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
2149
|
+
globalThis.addEventListener?.('focus', onVisible)
|
|
2150
|
+
return () => {
|
|
2151
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
2152
|
+
globalThis.removeEventListener?.('focus', onVisible)
|
|
2153
|
+
}
|
|
2154
|
+
}, [loadOverview])
|
|
2155
|
+
|
|
2156
|
+
React.useEffect(() => {
|
|
2157
|
+
if (notice === null) return undefined
|
|
2158
|
+
const timer = globalThis.setTimeout(() => {
|
|
2159
|
+
setNotice(null)
|
|
2160
|
+
}, NOTICE_TTL_MS)
|
|
2161
|
+
return () => {
|
|
2162
|
+
globalThis.clearTimeout(timer)
|
|
2163
|
+
}
|
|
2164
|
+
}, [notice])
|
|
2165
|
+
|
|
2166
|
+
/** Run one mutating route, then refresh so the panel shows its effect. */
|
|
2167
|
+
const run = React.useCallback(
|
|
2168
|
+
async (key, path, body, successKey, params) => {
|
|
2169
|
+
setBusy(key)
|
|
2170
|
+
setNotice(null)
|
|
2171
|
+
setError(null)
|
|
2172
|
+
const result = await postJson(path, body, ACTION_TIMEOUT_MS)
|
|
2173
|
+
setBusy('')
|
|
2174
|
+
if (!result.ok) {
|
|
2175
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2176
|
+
return
|
|
2177
|
+
}
|
|
2178
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2179
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2180
|
+
return
|
|
2181
|
+
}
|
|
2182
|
+
setNotice(t(successKey, params))
|
|
2183
|
+
await loadOverview({ force: true })
|
|
2184
|
+
},
|
|
2185
|
+
[loadOverview, setError, t],
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
const onAction = React.useCallback(
|
|
2189
|
+
(kind, data, extra) => {
|
|
2190
|
+
if (kind === 'build') {
|
|
2191
|
+
const workflow = status?.config?.buildWorkflow ?? 'ci.yml'
|
|
2192
|
+
void run(`build:${data.repo}`, '/dispatch', { repo: data.repo, workflow }, 'state.dispatched', { workflow, repo: data.repo })
|
|
2193
|
+
return
|
|
2194
|
+
}
|
|
2195
|
+
if (kind === 'release') {
|
|
2196
|
+
void run(`release:${data.repo}`, '/dispatch', { repo: data.repo, workflow: status?.config?.releaseWorkflow ?? 'release.yml', inputs: { draft: 'true' } }, 'state.releaseDispatched', { repo: data.repo })
|
|
2197
|
+
return
|
|
2198
|
+
}
|
|
2199
|
+
void run(`${kind}:${data.repo}`, '/run-action', { repo: data.repo, runId: extra?.runId, action: kind }, kind === 'cancel' ? 'state.cancelled' : 'state.rerun', {})
|
|
2200
|
+
},
|
|
2201
|
+
[run, status],
|
|
2202
|
+
)
|
|
2203
|
+
|
|
2204
|
+
const onPublish = React.useCallback(
|
|
2205
|
+
(repo, tag) => {
|
|
2206
|
+
void run(`publish:${repo}`, '/release-action', { repo, tag, action: 'publish' }, 'state.published', { tag })
|
|
2207
|
+
},
|
|
2208
|
+
[run],
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Bump the version, push it, then dispatch the release.
|
|
2213
|
+
*
|
|
2214
|
+
* One action rather than two buttons because neither half stands alone: a bump
|
|
2215
|
+
* with no release leaves a commit nobody asked for, and a release with no bump
|
|
2216
|
+
* is the run that could only fail. The new version is the Host's own
|
|
2217
|
+
* arithmetic — the number in the confirmation is the number that gets written —
|
|
2218
|
+
* and a failed bump reports why without dispatching anything.
|
|
2219
|
+
*/
|
|
2220
|
+
const onBump = React.useCallback(
|
|
2221
|
+
async (data) => {
|
|
2222
|
+
const key = `bump:${data.repo}`
|
|
2223
|
+
setBusy(key)
|
|
2224
|
+
setNotice(null)
|
|
2225
|
+
setError(null)
|
|
2226
|
+
const bumped = await postJson('/version-bump', { repo: data.repo, release: 'patch' }, ACTION_TIMEOUT_MS)
|
|
2227
|
+
if (!bumped.ok) {
|
|
2228
|
+
setBusy('')
|
|
2229
|
+
setError('timeout')
|
|
2230
|
+
return
|
|
2231
|
+
}
|
|
2232
|
+
if (!bumped.response.ok || bumped.payload?.ok !== true) {
|
|
2233
|
+
setBusy('')
|
|
2234
|
+
setError(t('state.actionFailed', { reason: releaseFailure(t, bumped.payload, bumped.response) }))
|
|
2235
|
+
await loadOverview({ force: true })
|
|
2236
|
+
return
|
|
2237
|
+
}
|
|
2238
|
+
const value = bumped.payload.value ?? {}
|
|
2239
|
+
const dispatched = await postJson('/dispatch', {
|
|
2240
|
+
repo: data.repo,
|
|
2241
|
+
workflow: status?.config?.releaseWorkflow ?? 'release.yml',
|
|
2242
|
+
inputs: { draft: 'true' },
|
|
2243
|
+
}, ACTION_TIMEOUT_MS)
|
|
2244
|
+
setBusy('')
|
|
2245
|
+
if (!dispatched.ok || !dispatched.response.ok || dispatched.payload?.ok !== true) {
|
|
2246
|
+
setError(t('state.actionFailed', { reason: releaseFailure(t, dispatched.payload, dispatched.response) }))
|
|
2247
|
+
await loadOverview({ force: true })
|
|
2248
|
+
return
|
|
2249
|
+
}
|
|
2250
|
+
setNotice(t('state.releaseBumped', {
|
|
2251
|
+
from: String(value.from ?? '?'),
|
|
2252
|
+
tag: String(value.tag ?? ''),
|
|
2253
|
+
branch: String(value.branch ?? ''),
|
|
2254
|
+
}))
|
|
2255
|
+
await loadOverview({ force: true })
|
|
2256
|
+
},
|
|
2257
|
+
[loadOverview, setError, status, t],
|
|
2258
|
+
)
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* Commit the working tree, and push it.
|
|
2262
|
+
*
|
|
2263
|
+
* The step 构建 and 发布 both depend on and neither can substitute for: a workflow
|
|
2264
|
+
* runs the pushed commit, so work that is still only on this disk is invisible to
|
|
2265
|
+
* both of them. `message === ''` means "push what is already committed".
|
|
2266
|
+
*/
|
|
2267
|
+
const onCommit = React.useCallback(
|
|
2268
|
+
async (data, message) => {
|
|
2269
|
+
const key = `commit:${data.repo}`
|
|
2270
|
+
setBusy(key)
|
|
2271
|
+
setNotice(null)
|
|
2272
|
+
setError(null)
|
|
2273
|
+
const result = await postJson('/commit', { repo: data.repo, message }, ACTION_TIMEOUT_MS)
|
|
2274
|
+
setBusy('')
|
|
2275
|
+
if (!result.ok) {
|
|
2276
|
+
setError('timeout')
|
|
2277
|
+
return
|
|
2278
|
+
}
|
|
2279
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2280
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2281
|
+
await loadOverview({ force: true })
|
|
2282
|
+
return
|
|
2283
|
+
}
|
|
2284
|
+
const value = result.payload.value ?? {}
|
|
2285
|
+
const branch = String(value.branch ?? '')
|
|
2286
|
+
setNotice(value.committed === true
|
|
2287
|
+
? t('state.committed', { count: String(Array.isArray(value.files) ? value.files.length : 0), branch })
|
|
2288
|
+
: t('state.pushed', { count: String(value.carried ?? 0), branch }))
|
|
2289
|
+
await loadOverview({ force: true })
|
|
2290
|
+
},
|
|
2291
|
+
[loadOverview, setError, t],
|
|
2292
|
+
)
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Fetch a working tree for a repository that has none.
|
|
2296
|
+
*
|
|
2297
|
+
* The address is whatever the field holds — the Host derives one when the panel
|
|
2298
|
+
* opens, and a person may have replaced it with a fork or a private remote.
|
|
2299
|
+
*/
|
|
2300
|
+
const onClone = React.useCallback(
|
|
2301
|
+
async (data, url) => {
|
|
2302
|
+
const key = `clone:${data.repo}`
|
|
2303
|
+
setBusy(key)
|
|
2304
|
+
setNotice(null)
|
|
2305
|
+
setError(null)
|
|
2306
|
+
const result = await postJson('/clone', { repo: data.repo, url }, ACTION_TIMEOUT_MS)
|
|
2307
|
+
setBusy('')
|
|
2308
|
+
if (!result.ok) {
|
|
2309
|
+
setError('timeout')
|
|
2310
|
+
return
|
|
2311
|
+
}
|
|
2312
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2313
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2314
|
+
await loadOverview({ force: true })
|
|
2315
|
+
return
|
|
2316
|
+
}
|
|
2317
|
+
setNotice(t('clone.done', { path: String(result.payload.value?.path ?? '') }))
|
|
2318
|
+
await loadOverview({ force: true })
|
|
2319
|
+
},
|
|
2320
|
+
[loadOverview, setError, t],
|
|
2321
|
+
)
|
|
2322
|
+
|
|
2323
|
+
/**
|
|
2324
|
+
* Install the release into this profile, then ask about the restart.
|
|
2325
|
+
*
|
|
2326
|
+
* Deliberately not routed through `run`: that helper reports success with one
|
|
2327
|
+
* sentence and refreshes, and this action's result is a question. The Host is
|
|
2328
|
+
* the one that says whether a restart is needed (`restartRequired`), so the
|
|
2329
|
+
* prompt follows the answer instead of assuming it.
|
|
2330
|
+
*/
|
|
2331
|
+
const onUpdate = React.useCallback(
|
|
2332
|
+
async (data) => {
|
|
2333
|
+
const key = `update:${data.repo}`
|
|
2334
|
+
const tag = String(data?.install?.latestTag ?? '')
|
|
2335
|
+
setBusy(key)
|
|
2336
|
+
setNotice(null)
|
|
2337
|
+
setError(null)
|
|
2338
|
+
setRestartPrompt(null)
|
|
2339
|
+
const result = await postJson('/update', { repo: data.repo }, UPDATE_TIMEOUT_MS)
|
|
2340
|
+
setBusy('')
|
|
2341
|
+
if (!result.ok) {
|
|
2342
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2343
|
+
return
|
|
2344
|
+
}
|
|
2345
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2346
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2347
|
+
await loadOverview({ force: true })
|
|
2348
|
+
return
|
|
2349
|
+
}
|
|
2350
|
+
const value = result.payload.value ?? {}
|
|
2351
|
+
const builds = Array.isArray(value.pendingBuilds) ? value.pendingBuilds.length : 0
|
|
2352
|
+
/*
|
|
2353
|
+
* `changed: false` is the honest answer when the profile already pointed at
|
|
2354
|
+
* exactly this tarball: nothing was written, so nothing needs restarting,
|
|
2355
|
+
* and saying "已从 X 换成 Y" would describe a write that did not happen.
|
|
2356
|
+
*/
|
|
2357
|
+
setNotice(value.changed === false
|
|
2358
|
+
? t('state.updatedSame', { package: String(value.packageName ?? data.repo), tag: String(value.tag ?? tag) })
|
|
2359
|
+
: t('state.updated', {
|
|
2360
|
+
package: String(value.packageName ?? data.repo),
|
|
2361
|
+
from: value.from === null || value.from === undefined ? '—' : `v${String(value.from)}`,
|
|
2362
|
+
tag: String(value.tag ?? tag),
|
|
2363
|
+
}) + (builds > 0 ? ` ${t('state.updatedBuilds', { count: String(builds) })}` : ''))
|
|
2364
|
+
if (value.restartRequired === true) {
|
|
2365
|
+
setRestartPrompt({ package: String(value.packageName ?? data.repo), tag: String(value.tag ?? tag) })
|
|
2366
|
+
}
|
|
2367
|
+
await loadOverview({ force: true })
|
|
2368
|
+
},
|
|
2369
|
+
[loadOverview, setError, t],
|
|
2370
|
+
)
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* Ask the one-click restart plugin to restart DSH.
|
|
2374
|
+
*
|
|
2375
|
+
* The Host forwards this to `/api/dsh-restart/restart` on its own authority, so
|
|
2376
|
+
* a missing restart plugin comes back as a named 501 rather than a bare
|
|
2377
|
+
* failure — and this page never talks to another plugin directly.
|
|
2378
|
+
*/
|
|
2379
|
+
const onRestart = React.useCallback(async () => {
|
|
2380
|
+
setBusy('restart')
|
|
2381
|
+
setError(null)
|
|
2382
|
+
const result = await postJson('/restart', {}, ACTION_TIMEOUT_MS)
|
|
2383
|
+
setBusy('')
|
|
2384
|
+
if (!result.ok) {
|
|
2385
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2386
|
+
return
|
|
2387
|
+
}
|
|
2388
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2389
|
+
setError(t('restart.unavailable', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2390
|
+
return
|
|
2391
|
+
}
|
|
2392
|
+
setRestartPrompt(null)
|
|
2393
|
+
setNotice(t('restart.scheduled'))
|
|
2394
|
+
}, [setError, t])
|
|
2395
|
+
|
|
2396
|
+
/**
|
|
2397
|
+
* Push one package to npm.
|
|
2398
|
+
*
|
|
2399
|
+
* The one-time password is passed through and then thrown away, and every
|
|
2400
|
+
* refusal comes back as the Host's own sentence — `private-package`,
|
|
2401
|
+
* `dirty-tree` and `already-published` are three different fixes and the panel
|
|
2402
|
+
* does not try to paraphrase them.
|
|
2403
|
+
*/
|
|
2404
|
+
const onNpmPublish = React.useCallback(
|
|
2405
|
+
async (data, otp) => {
|
|
2406
|
+
const key = `npm:${data.repo}`
|
|
2407
|
+
setBusy(key)
|
|
2408
|
+
setNotice(null)
|
|
2409
|
+
setError(null)
|
|
2410
|
+
const result = await postJson('/npm-publish', { repo: data.repo, otp: String(otp ?? '') }, UPDATE_TIMEOUT_MS)
|
|
2411
|
+
setBusy('')
|
|
2412
|
+
if (!result.ok) {
|
|
2413
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2414
|
+
return
|
|
2415
|
+
}
|
|
2416
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2417
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2418
|
+
/* The named outcome is what turns npm's own words into a next step. */
|
|
2419
|
+
setNpmFailure(String(result.payload?.code ?? 'unknown'))
|
|
2420
|
+
await loadNpm({ force: true })
|
|
2421
|
+
return
|
|
2422
|
+
}
|
|
2423
|
+
setNpmFailure(null)
|
|
2424
|
+
const value = result.payload.value ?? {}
|
|
2425
|
+
setNotice(t(value.wasUnregistered === true ? 'state.npmFirstPublish' : 'state.npmPublished', {
|
|
2426
|
+
package: String(value.packageName ?? data.repo),
|
|
2427
|
+
version: String(value.version ?? ''),
|
|
2428
|
+
registry: String(value.registry ?? ''),
|
|
2429
|
+
}))
|
|
2430
|
+
await loadNpm({ force: true })
|
|
2431
|
+
},
|
|
2432
|
+
[loadNpm, setError, t],
|
|
2433
|
+
)
|
|
2434
|
+
|
|
2435
|
+
/**
|
|
2436
|
+
* Write an npm token where npm itself reads it, and prove it works.
|
|
2437
|
+
*
|
|
2438
|
+
* The promise the panel makes about credentials is the one it already keeps for
|
|
2439
|
+
* `gh`: it stores none. The token goes into the user-level `.npmrc` — the same
|
|
2440
|
+
* file `npm login` writes — and is cleared from the field as soon as the Host
|
|
2441
|
+
* answers, so it is not left sitting in a page that stays open for hours.
|
|
2442
|
+
*/
|
|
2443
|
+
const onNpmLogin = React.useCallback(async () => {
|
|
2444
|
+
setBusy('npm-login')
|
|
2445
|
+
setNotice(null)
|
|
2446
|
+
setError(null)
|
|
2447
|
+
const result = await postJson('/npm-login', { token: npmToken }, ACTION_TIMEOUT_MS)
|
|
2448
|
+
setBusy('')
|
|
2449
|
+
setNpmToken('')
|
|
2450
|
+
if (!result.ok) {
|
|
2451
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2452
|
+
return
|
|
2453
|
+
}
|
|
2454
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2455
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2456
|
+
setNpmTokenRejected(result.payload?.code === 'token-rejected')
|
|
2457
|
+
await loadNpm({ force: true })
|
|
2458
|
+
return
|
|
2459
|
+
}
|
|
2460
|
+
setNpmTokenRejected(false)
|
|
2461
|
+
const value = result.payload.value ?? {}
|
|
2462
|
+
setNotice(t('npm.tokenWritten', { npmrc: String(value.npmrcPath ?? ''), account: String(value.account ?? '') }))
|
|
2463
|
+
await loadNpm({ force: true })
|
|
2464
|
+
}, [loadNpm, npmToken, setError, t])
|
|
2465
|
+
|
|
2466
|
+
const onLogs = React.useCallback(async (data, entry) => {
|
|
2467
|
+
if (logs !== null && logs.repo === data.repo && logs.runId === entry.id) {
|
|
2468
|
+
setLogs(null)
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
const result = await postJson('/logs', { repo: data.repo, runId: entry.id }, ACTION_TIMEOUT_MS)
|
|
2472
|
+
if (!result.ok || !result.response.ok || result.payload?.ok !== true) {
|
|
2473
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? 'logs unavailable' }))
|
|
2474
|
+
return
|
|
2475
|
+
}
|
|
2476
|
+
setLogs({ repo: data.repo, runId: entry.id, lines: result.payload.value?.lines ?? [], truncated: result.payload.value?.truncated === true })
|
|
2477
|
+
}, [logs, setError, t])
|
|
2478
|
+
|
|
2479
|
+
/**
|
|
2480
|
+
* Bind or unbind a repository's video.
|
|
2481
|
+
*
|
|
2482
|
+
* `auto` is carried through on every save so that toggling the switch does not
|
|
2483
|
+
* silently re-enable a binding someone deliberately made manual — the Host
|
|
2484
|
+
* takes the whole binding on each write, and the panel sends the whole binding.
|
|
2485
|
+
*/
|
|
2486
|
+
const onBiliBind = React.useCallback(
|
|
2487
|
+
async (data, bvid, auto) => {
|
|
2488
|
+
const key = `bili-bind:${data.repo}`
|
|
2489
|
+
setBusy(key)
|
|
2490
|
+
setNotice(null)
|
|
2491
|
+
setError(null)
|
|
2492
|
+
const result = await postJson('/bilibili-bind', { repo: data.repo, bvid: String(bvid ?? ''), auto: auto !== false }, ACTION_TIMEOUT_MS)
|
|
2493
|
+
setBusy('')
|
|
2494
|
+
if (!result.ok) {
|
|
2495
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2496
|
+
return
|
|
2497
|
+
}
|
|
2498
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2499
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2500
|
+
await loadBili({ force: true })
|
|
2501
|
+
return
|
|
2502
|
+
}
|
|
2503
|
+
const value = result.payload.value ?? {}
|
|
2504
|
+
if (String(value.bvid ?? '') === '') {
|
|
2505
|
+
setNotice(t('bili.unbound.done', { repo: data.repo }))
|
|
2506
|
+
} else {
|
|
2507
|
+
const baseline = value.baseline
|
|
2508
|
+
? t('bili.bind.baseline', { tag: String(value.baseline) })
|
|
2509
|
+
: (value.note ? t('bili.bind.baselineUnknown') : '')
|
|
2510
|
+
setNotice(t('bili.bind.done', { bvid: String(value.bvid), baseline }))
|
|
2511
|
+
}
|
|
2512
|
+
await loadBili({ force: true })
|
|
2513
|
+
await loadOverview({ force: true })
|
|
2514
|
+
},
|
|
2515
|
+
[loadBili, loadOverview, setError, t],
|
|
2516
|
+
)
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* Ask the Host what the comment would say, without sending it.
|
|
2520
|
+
*
|
|
2521
|
+
* A dry run rather than a client-side composition: the template and the
|
|
2522
|
+
* summary are the Host's, and a page that assembled its own preview could
|
|
2523
|
+
* show one sentence and post another.
|
|
2524
|
+
*/
|
|
2525
|
+
const onBiliPreview = React.useCallback(
|
|
2526
|
+
async (data, tag) => {
|
|
2527
|
+
const key = `bili-preview:${data.repo}`
|
|
2528
|
+
setBusy(key)
|
|
2529
|
+
setError(null)
|
|
2530
|
+
const result = await postJson('/bilibili-announce', { repo: data.repo, tag: String(tag ?? ''), dryRun: true }, ACTION_TIMEOUT_MS)
|
|
2531
|
+
setBusy('')
|
|
2532
|
+
if (!result.ok) {
|
|
2533
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2534
|
+
return null
|
|
2535
|
+
}
|
|
2536
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2537
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2538
|
+
return null
|
|
2539
|
+
}
|
|
2540
|
+
return result.payload.value ?? null
|
|
2541
|
+
},
|
|
2542
|
+
[setError, t],
|
|
2543
|
+
)
|
|
2544
|
+
|
|
2545
|
+
/** Send the previewed comment, exactly as previewed unless it was edited. */
|
|
2546
|
+
const onBiliAnnounce = React.useCallback(
|
|
2547
|
+
async (data, preview, text, force) => {
|
|
2548
|
+
const key = `bili-announce:${data.repo}`
|
|
2549
|
+
setBusy(key)
|
|
2550
|
+
setNotice(null)
|
|
2551
|
+
setError(null)
|
|
2552
|
+
const result = await postJson('/bilibili-announce', {
|
|
2553
|
+
repo: data.repo,
|
|
2554
|
+
tag: String(preview?.tag ?? ''),
|
|
2555
|
+
text: String(text ?? ''),
|
|
2556
|
+
force: force === true,
|
|
2557
|
+
}, ACTION_TIMEOUT_MS)
|
|
2558
|
+
setBusy('')
|
|
2559
|
+
if (!result.ok) {
|
|
2560
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2561
|
+
return null
|
|
2562
|
+
}
|
|
2563
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2564
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2565
|
+
await loadBili({ force: true })
|
|
2566
|
+
return null
|
|
2567
|
+
}
|
|
2568
|
+
const value = result.payload.value ?? {}
|
|
2569
|
+
/*
|
|
2570
|
+
* Bilibili accepted it; that is not the same as a reader finding it. The Host
|
|
2571
|
+
* now looks the comment back up before answering, so the panel can say which of
|
|
2572
|
+
* the two happened instead of asserting the cheerful one.
|
|
2573
|
+
*/
|
|
2574
|
+
setNotice(value.visible === false
|
|
2575
|
+
? t('bili.announce.unverified', { bvid: String(value.bvid ?? ''), tag: String(value.tag ?? '') })
|
|
2576
|
+
: t('bili.announce.sent', { bvid: String(value.bvid ?? ''), tag: String(value.tag ?? '') }))
|
|
2577
|
+
await loadBili({ force: true })
|
|
2578
|
+
return value
|
|
2579
|
+
},
|
|
2580
|
+
[loadBili, setError, t],
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
/**
|
|
2584
|
+
* The configured list comes from `/status`, the loaded detail from
|
|
2585
|
+
* `/overview`.
|
|
2586
|
+
*
|
|
2587
|
+
* They are not interchangeable. `/status` is cheap and knows the
|
|
2588
|
+
* configuration; `/overview` fans out three GitHub calls per repository and
|
|
2589
|
+
* takes a moment. Deciding "is anything registered?" from the expensive one
|
|
2590
|
+
* meant that any moment before it landed — or any failure — made the panel
|
|
2591
|
+
* announce 还没有登记仓库 to someone who had just registered five, and it hid
|
|
2592
|
+
* the reason at the same time.
|
|
2593
|
+
*/
|
|
2594
|
+
const configured = status?.repos ?? []
|
|
2595
|
+
const repos = overview?.repos ?? []
|
|
2596
|
+
const gh = status?.gh ?? {}
|
|
2597
|
+
/**
|
|
2598
|
+
* npm's answers, keyed by the configured repository string.
|
|
2599
|
+
*
|
|
2600
|
+
* The overview and the npm status arrive independently, so a row renders
|
|
2601
|
+
* without its npm chip until the second answer lands — and renders without one
|
|
2602
|
+
* forever if that answer never comes, which is the point.
|
|
2603
|
+
*/
|
|
2604
|
+
const npmByRepo = new Map((npm?.repos ?? []).map((entry) => [entry.repo, entry]))
|
|
2605
|
+
const npmAuth = npm?.auth ?? null
|
|
2606
|
+
/**
|
|
2607
|
+
* Bilibili's answers, keyed the same way and arriving independently.
|
|
2608
|
+
*
|
|
2609
|
+
* A row renders without its binding block until this lands, which is the
|
|
2610
|
+
* honest state: the panel has not been told which video this repository
|
|
2611
|
+
* speaks to yet.
|
|
2612
|
+
*/
|
|
2613
|
+
const biliByRepo = new Map((bili?.repos ?? []).map((entry) => [entry.repo, entry]))
|
|
2614
|
+
/**
|
|
2615
|
+
* Whether the Bilibili side is blocked on a credential.
|
|
2616
|
+
*
|
|
2617
|
+
* Shown in the panel only when a video is actually bound and the credential
|
|
2618
|
+
* cannot post: a repository nobody bound must not be nagged about a feature
|
|
2619
|
+
* that is not in use, and a bound one that cannot post is exactly the state
|
|
2620
|
+
* where a silent sweep would fail forever.
|
|
2621
|
+
*/
|
|
2622
|
+
const biliBlocked = bili !== null
|
|
2623
|
+
&& (bili.repos ?? []).some((entry) => entry.binding !== null)
|
|
2624
|
+
&& bili.credential?.state !== 'ready'
|
|
2625
|
+
/**
|
|
2626
|
+
* Three states, not a boolean — the Host's `npmAuthState`.
|
|
2627
|
+
*
|
|
2628
|
+
* A granular token is scoped to packages, so `whoami` (a user-level endpoint)
|
|
2629
|
+
* can refuse it while `publish` accepts it. Collapsing that into "not signed in"
|
|
2630
|
+
* would show the four-step guide to someone who just finished step 3.
|
|
2631
|
+
*/
|
|
2632
|
+
const npmCredential = npmAuth === null
|
|
2633
|
+
// No answer is not "no credential": rendering the four-step guide here would
|
|
2634
|
+
// claim the machine has nothing to authenticate with, which nobody has said.
|
|
2635
|
+
? 'unknown'
|
|
2636
|
+
: String(npmAuth.state ?? (npmAuth.loggedIn === true ? 'signed-in' : 'none'))
|
|
2637
|
+
const npmHintText = (() => {
|
|
2638
|
+
if (npmFailure === null) return null
|
|
2639
|
+
const text = t(`npm.hint.${npmFailure}`)
|
|
2640
|
+
return text === `npm.hint.${npmFailure}` ? null : text
|
|
2641
|
+
})()
|
|
2642
|
+
const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
|
|
2643
|
+
const setupNeeded = status !== null && (needsAccountSetup(gh) || configured.length === 0)
|
|
2644
|
+
const draftCount = repos.filter((entry) => entry.draftTag !== null && entry.draftTag !== undefined).length
|
|
2645
|
+
/** Configured repositories whose detail has not arrived yet, shown as placeholders. */
|
|
2646
|
+
const pendingRows = configured.length > repos.length
|
|
2647
|
+
? configured
|
|
2648
|
+
.filter((entry) => !repos.some((loaded) => loaded.repo === entry.repo))
|
|
2649
|
+
.map((entry) => ({
|
|
2650
|
+
repo: entry.repo,
|
|
2651
|
+
label: entry.label,
|
|
2652
|
+
localPath: entry.localPath,
|
|
2653
|
+
pending: true,
|
|
2654
|
+
version: null,
|
|
2655
|
+
versionKnown: false,
|
|
2656
|
+
expectedTag: null,
|
|
2657
|
+
published: false,
|
|
2658
|
+
publishedTag: null,
|
|
2659
|
+
draftTag: null,
|
|
2660
|
+
latestRun: null,
|
|
2661
|
+
runs: [],
|
|
2662
|
+
releases: [],
|
|
2663
|
+
workflows: [],
|
|
2664
|
+
hasBuildWorkflow: false,
|
|
2665
|
+
hasReleaseWorkflow: false,
|
|
2666
|
+
problems: [],
|
|
2667
|
+
local: { available: false },
|
|
2668
|
+
}))
|
|
2669
|
+
: []
|
|
2670
|
+
// The page and the Host half load independently, so they can disagree.
|
|
2671
|
+
const stale = status !== null && status.protocol !== PROTOCOL
|
|
2672
|
+
|
|
2673
|
+
return h(
|
|
2674
|
+
'div',
|
|
2675
|
+
{ className: 'dsc-root' },
|
|
2676
|
+
h(
|
|
2677
|
+
'div',
|
|
2678
|
+
{ className: 'dsc-bar' },
|
|
2679
|
+
h(
|
|
2680
|
+
'div',
|
|
2681
|
+
{ className: 'dsc-heading' },
|
|
2682
|
+
h('span', { className: 'dsc-title' }, t('title')),
|
|
2683
|
+
h('span', { className: 'dsc-subtitle' }, t('subtitle')),
|
|
2684
|
+
),
|
|
2685
|
+
h(
|
|
2686
|
+
'div',
|
|
2687
|
+
{ className: 'dsc-meta' },
|
|
2688
|
+
gh.account !== null && gh.account !== undefined ? h(Chip, { state: 'idle' }, `${t('meta.account')} ${gh.account}`) : null,
|
|
2689
|
+
npmAuth !== null && npmAuth.loggedIn === true
|
|
2690
|
+
? h(Chip, { state: 'idle', title: String(npm.registry ?? '') }, t('npm.account', { account: String(npmAuth.account ?? '') }))
|
|
2691
|
+
: null,
|
|
2692
|
+
overview !== null && typeof overview.fetchedAt === 'string'
|
|
2693
|
+
? h('span', null, t('meta.updated', { time: stamp(overview.fetchedAt) }), overview.cached === true ? ` (${t('meta.cached')})` : '')
|
|
2694
|
+
: null,
|
|
2695
|
+
h(Btn, { onClick: () => { void loadStatus(); void loadOverview({ force: true }) } }, t('action.refresh')),
|
|
2696
|
+
h(Btn, { kind: managing ? 'primary' : undefined, onClick: () => setManaging((value) => !value) }, managing ? t('action.close') : t('action.manage')),
|
|
2697
|
+
),
|
|
2698
|
+
),
|
|
2699
|
+
|
|
2700
|
+
error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
|
|
2701
|
+
/* The next step for a named npm failure. Rendered only when a hint exists for
|
|
2702
|
+
that code, so an unmapped name can never print a dictionary key on screen. */
|
|
2703
|
+
npmHintText !== null ? h('div', { className: 'dsc-warn', role: 'status' }, npmHintText) : null,
|
|
2704
|
+
notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
|
|
2705
|
+
/* The question an update leaves behind. It sits above the list rather than
|
|
2706
|
+
inside the row, because the row is about a repository and this is about
|
|
2707
|
+
the application the user is looking at. */
|
|
2708
|
+
restartPrompt !== null
|
|
2709
|
+
? h(
|
|
2710
|
+
'div',
|
|
2711
|
+
{ className: 'dsc-setup', role: 'status' },
|
|
2712
|
+
h('span', { className: 'dsc-setup-strong' }, t('restart.ask', { package: restartPrompt.package, tag: restartPrompt.tag })),
|
|
2713
|
+
h(
|
|
2714
|
+
'div',
|
|
2715
|
+
{ className: 'dsc-setup-line' },
|
|
2716
|
+
h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void onRestart() } }, busy === 'restart' ? '…' : t('action.restartNow')),
|
|
2717
|
+
h(Btn, { kind: 'quiet', onClick: () => setRestartPrompt(null) }, t('action.restartLater')),
|
|
2718
|
+
),
|
|
2719
|
+
)
|
|
2720
|
+
: null,
|
|
2721
|
+
/*
|
|
2722
|
+
* The npm credential, in the operational view.
|
|
2723
|
+
*
|
|
2724
|
+
* The four steps live on their own settings page, because that is where
|
|
2725
|
+
* configuration belongs and where every package's npm state is listed. Here the
|
|
2726
|
+
* panel says which of the two states this machine is in, offers the one control
|
|
2727
|
+
* that resolves it, and points at the guide — so a first-time publisher who
|
|
2728
|
+
* lands on the push button still has a way forward without a terminal, without
|
|
2729
|
+
* four steps of onboarding sitting above a live repository list.
|
|
2730
|
+
*/
|
|
2731
|
+
npmCredential === 'none' || npmCredential === 'credential-present'
|
|
2732
|
+
? h(
|
|
2733
|
+
'div',
|
|
2734
|
+
{ className: 'dsc-setup', role: 'status' },
|
|
2735
|
+
h('span', { className: 'dsc-setup-strong' }, npmCredential === 'none'
|
|
2736
|
+
? t('npm.notLoggedIn', {
|
|
2737
|
+
command: String(npm?.packageManager?.command ?? 'pnpm'),
|
|
2738
|
+
registry: String(npm?.registry ?? ''),
|
|
2739
|
+
})
|
|
2740
|
+
: t('npm.credentialPresentTitle')),
|
|
2741
|
+
npmCredential === 'none'
|
|
2742
|
+
? null
|
|
2743
|
+
: h('span', null, t('npm.credentialPresentShort', { registry: String(npm?.registry ?? '') })),
|
|
2744
|
+
/* What `whoami` actually said, because "not signed in" and "this token was
|
|
2745
|
+
revoked" look identical and need different fixes. */
|
|
2746
|
+
npmAuth?.message !== null && npmAuth?.message !== undefined
|
|
2747
|
+
? h('span', { className: 'dsc-mono' }, String(npmAuth.message))
|
|
2748
|
+
: null,
|
|
2749
|
+
h(
|
|
2750
|
+
'div',
|
|
2751
|
+
{ className: 'dsc-setup-line' },
|
|
2752
|
+
h('input', {
|
|
2753
|
+
className: 'dsc-input',
|
|
2754
|
+
type: 'password',
|
|
2755
|
+
autoComplete: 'off',
|
|
2756
|
+
spellCheck: 'false',
|
|
2757
|
+
placeholder: t('npm.tokenPlaceholder'),
|
|
2758
|
+
'aria-label': t('npm.tokenPlaceholder'),
|
|
2759
|
+
value: npmToken,
|
|
2760
|
+
onChange: (event) => setNpmToken(String(event?.target?.value ?? '')),
|
|
2761
|
+
}),
|
|
2762
|
+
h(Btn, {
|
|
2763
|
+
kind: 'primary',
|
|
2764
|
+
disabled: busy !== '' || npmToken.trim() === '',
|
|
2765
|
+
onClick: () => { void onNpmLogin() },
|
|
2766
|
+
}, busy === 'npm-login' ? '…' : t('npm.writeToken')),
|
|
2767
|
+
h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void loadNpm({ force: true }) } }, t('npm.recheck')),
|
|
2768
|
+
),
|
|
2769
|
+
npmTokenRejected
|
|
2770
|
+
? h(
|
|
2771
|
+
React.Fragment,
|
|
2772
|
+
null,
|
|
2773
|
+
h('span', { className: 'dsc-warn' }, t('npm.rejectedTitle')),
|
|
2774
|
+
h('span', null, t('npm.rejectedCauses')),
|
|
2775
|
+
)
|
|
2776
|
+
: null,
|
|
2777
|
+
h('span', { className: 'dsc-quiet' }, t('npm.moreInSettings')),
|
|
2778
|
+
)
|
|
2779
|
+
: null,
|
|
2780
|
+
stale
|
|
2781
|
+
? h('div', { className: 'dsc-warn', role: 'alert' }, h('strong', null, t('stale.title')), ' — ', t('stale.how'))
|
|
2782
|
+
: null,
|
|
2783
|
+
/*
|
|
2784
|
+
* A bound video with no usable credential is the one Bilibili state the
|
|
2785
|
+
* panel has to raise on its own: every subsequent release would silently
|
|
2786
|
+
* fail to announce, and a failure nobody sees is the shape this project
|
|
2787
|
+
* keeps writing tests against.
|
|
2788
|
+
*/
|
|
2789
|
+
biliBlocked && !stale
|
|
2790
|
+
? h(
|
|
2791
|
+
'div',
|
|
2792
|
+
{ className: 'dsc-setup', role: 'status' },
|
|
2793
|
+
h('span', { className: 'dsc-setup-strong' }, t('bili.needCredential')),
|
|
2794
|
+
h('span', { className: 'dsc-quiet' }, t('bili.openSettings')),
|
|
2795
|
+
h(BilibiliSignIn, {
|
|
2796
|
+
t,
|
|
2797
|
+
bili,
|
|
2798
|
+
busy,
|
|
2799
|
+
setBusy,
|
|
2800
|
+
setNotice,
|
|
2801
|
+
setError,
|
|
2802
|
+
onChanged: () => loadBili({ force: true }),
|
|
2803
|
+
}),
|
|
2804
|
+
)
|
|
2805
|
+
: null,
|
|
2806
|
+
/* The one outcome the panel could report as "nothing happened". Say it, and
|
|
2807
|
+
say what to press, instead of leaving it inside a collapsed row. */
|
|
2808
|
+
draftCount > 0
|
|
2809
|
+
? h(
|
|
2810
|
+
'div',
|
|
2811
|
+
{ className: 'dsc-warn', role: 'status' },
|
|
2812
|
+
h('strong', null, t('drafts.title', { count: String(draftCount) })),
|
|
2813
|
+
' — ',
|
|
2814
|
+
t('drafts.text'),
|
|
2815
|
+
)
|
|
2816
|
+
: null,
|
|
2817
|
+
|
|
2818
|
+
setupNeeded && !stale
|
|
2819
|
+
? h(
|
|
2820
|
+
'div',
|
|
2821
|
+
{ className: 'dsc-setup' },
|
|
2822
|
+
h('span', { className: 'dsc-setup-strong' }, gh.available !== true
|
|
2823
|
+
? t('setup.needGh')
|
|
2824
|
+
: gh.authenticated !== true
|
|
2825
|
+
? t('setup.needSignIn')
|
|
2826
|
+
: missing.length > 0
|
|
2827
|
+
? t('setup.needScopes', { scopes: missing.join(', ') })
|
|
2828
|
+
: t('setup.needRepos')),
|
|
2829
|
+
missing.length > 0 ? h('span', null, t('setup.scopeHint')) : null,
|
|
2830
|
+
h(SignIn, { t, status, onChanged: () => { void loadStatus(); void loadOverview({ force: true }) } }),
|
|
2831
|
+
h(
|
|
2832
|
+
'details',
|
|
2833
|
+
null,
|
|
2834
|
+
h('summary', null, t('setup.advanced')),
|
|
2835
|
+
h(
|
|
2836
|
+
'div',
|
|
2837
|
+
{ className: 'dsc-sub' },
|
|
2838
|
+
h('span', null, t('setup.ghInstall')),
|
|
2839
|
+
h(CopyLine, { t, command: 'winget install --id GitHub.cli' }),
|
|
2840
|
+
h(CopyLine, { t, command: `node "${status?.helper?.configureScript ?? 'scripts/configure.mjs'}" list` }),
|
|
2841
|
+
),
|
|
2842
|
+
),
|
|
2843
|
+
)
|
|
2844
|
+
: null,
|
|
2845
|
+
|
|
2846
|
+
managing && !stale ? h(RepoPicker, { t, onChanged: () => { void loadStatus(); void loadOverview({ force: true }) } }) : null,
|
|
2847
|
+
|
|
2848
|
+
repos.length > 0 || pendingRows.length > 0
|
|
2849
|
+
? h(
|
|
2850
|
+
'div',
|
|
2851
|
+
{ className: 'dsc-list' },
|
|
2852
|
+
repos.map((data) => h(RepoRow, {
|
|
2853
|
+
key: data.repo,
|
|
2854
|
+
t,
|
|
2855
|
+
data,
|
|
2856
|
+
busy,
|
|
2857
|
+
onAction,
|
|
2858
|
+
onBump: () => onBump(data),
|
|
2859
|
+
onCommit: (row, message) => { void onCommit(row, message) },
|
|
2860
|
+
onClone: (row, url) => { void onClone(row, url) },
|
|
2861
|
+
onPublish: (tag) => onPublish(data.repo, tag),
|
|
2862
|
+
onUpdate: () => { void onUpdate(data) },
|
|
2863
|
+
npm: npmByRepo.get(data.repo) ?? null,
|
|
2864
|
+
onNpmPublish,
|
|
2865
|
+
bili: biliByRepo.get(data.repo) ?? null,
|
|
2866
|
+
onBiliBind,
|
|
2867
|
+
onBiliPreview,
|
|
2868
|
+
onBiliAnnounce,
|
|
2869
|
+
logs,
|
|
2870
|
+
onLogs,
|
|
2871
|
+
})),
|
|
2872
|
+
// Registered but not read yet: show the row with an honest "reading"
|
|
2873
|
+
// state instead of rendering nothing and implying an empty list.
|
|
2874
|
+
pendingRows.map((data) => h(RepoRow, {
|
|
2875
|
+
key: data.repo,
|
|
2876
|
+
t,
|
|
2877
|
+
data,
|
|
2878
|
+
busy,
|
|
2879
|
+
onAction,
|
|
2880
|
+
onBump: () => {},
|
|
2881
|
+
onCommit: () => {},
|
|
2882
|
+
onClone: () => {},
|
|
2883
|
+
onPublish: () => {},
|
|
2884
|
+
logs: null,
|
|
2885
|
+
onLogs: async () => {},
|
|
2886
|
+
})),
|
|
2887
|
+
)
|
|
2888
|
+
: null,
|
|
2889
|
+
)
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* The npm credential control, shared by the panel and the settings page.
|
|
2894
|
+
*
|
|
2895
|
+
* One implementation because the two surfaces must not drift: the panel shows the
|
|
2896
|
+
* short state and the settings page shows the four steps, but both write the same
|
|
2897
|
+
* file through the same route and have to report the same outcome. The token lives
|
|
2898
|
+
* here only until the request that writes it answers, and is cleared then.
|
|
2899
|
+
*/
|
|
2900
|
+
function useNpmCredential(t, loadNpm, setError, setNotice) {
|
|
2901
|
+
const [token, setToken] = React.useState('')
|
|
2902
|
+
const [busy, setBusy] = React.useState(false)
|
|
2903
|
+
const [rejected, setRejected] = React.useState(false)
|
|
2904
|
+
const login = React.useCallback(async () => {
|
|
2905
|
+
setBusy(true)
|
|
2906
|
+
setRejected(false)
|
|
2907
|
+
setError(null)
|
|
2908
|
+
const result = await postJson('/npm-login', { token }, ACTION_TIMEOUT_MS)
|
|
2909
|
+
setBusy(false)
|
|
2910
|
+
setToken('')
|
|
2911
|
+
if (!result.ok) {
|
|
2912
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
2913
|
+
return
|
|
2914
|
+
}
|
|
2915
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
2916
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
2917
|
+
setRejected(result.payload?.code === 'token-rejected')
|
|
2918
|
+
await loadNpm({ force: true })
|
|
2919
|
+
return
|
|
2920
|
+
}
|
|
2921
|
+
setRejected(false)
|
|
2922
|
+
const value = result.payload.value ?? {}
|
|
2923
|
+
setNotice(t('npm.tokenWritten', { npmrc: String(value.npmrcPath ?? ''), account: String(value.account ?? '') }))
|
|
2924
|
+
await loadNpm({ force: true })
|
|
2925
|
+
}, [loadNpm, setError, setNotice, t, token])
|
|
2926
|
+
return { token, setToken, busy, rejected, login }
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/** The token field and its two buttons — the control step ③ is about. */
|
|
2930
|
+
function NpmTokenLine(props) {
|
|
2931
|
+
const { t, credential, busy } = props
|
|
2932
|
+
return h(
|
|
2933
|
+
'div',
|
|
2934
|
+
{ className: 'dsc-setup-line' },
|
|
2935
|
+
h('input', {
|
|
2936
|
+
className: 'dsc-input',
|
|
2937
|
+
type: 'password',
|
|
2938
|
+
autoComplete: 'off',
|
|
2939
|
+
spellCheck: 'false',
|
|
2940
|
+
placeholder: t('npm.tokenPlaceholder'),
|
|
2941
|
+
'aria-label': t('npm.tokenPlaceholder'),
|
|
2942
|
+
value: credential.token,
|
|
2943
|
+
onChange: (event) => credential.setToken(String(event?.target?.value ?? '')),
|
|
2944
|
+
}),
|
|
2945
|
+
h(Btn, {
|
|
2946
|
+
kind: 'primary',
|
|
2947
|
+
disabled: busy !== '' || credential.token.trim() === '',
|
|
2948
|
+
onClick: () => { void credential.login() },
|
|
2949
|
+
}, credential.busy ? '…' : t('npm.writeToken')),
|
|
2950
|
+
)
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
/** The checklist a refused token needs, shown exactly when it is refused. */
|
|
2954
|
+
function NpmRejectedNote(props) {
|
|
2955
|
+
if (props.rejected !== true) return null
|
|
2956
|
+
return h(
|
|
2957
|
+
React.Fragment,
|
|
2958
|
+
null,
|
|
2959
|
+
h('span', { className: 'dsc-warn' }, props.t('npm.rejectedTitle')),
|
|
2960
|
+
h('span', null, props.t('npm.rejectedCauses')),
|
|
2961
|
+
)
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/**
|
|
2965
|
+
* The four steps, as a configuration guide rather than a paragraph.
|
|
2966
|
+
*
|
|
2967
|
+
* A step gets a status only where the Host can actually know one. It can see a
|
|
2968
|
+
* token line in `.npmrc`, and it can see an account once `whoami` answers — it
|
|
2969
|
+
* cannot see whether an account was registered or a token was generated, and a
|
|
2970
|
+
* guide that ticked those off would be inventing progress. So ① and ② carry a
|
|
2971
|
+
* tick only after a credential proves they happened, and say "cannot be detected"
|
|
2972
|
+
* until then; ③ is the one step with a live state of its own.
|
|
2973
|
+
*/
|
|
2974
|
+
function NpmCredentialGuide(props) {
|
|
2975
|
+
const { t, npm, credential } = props
|
|
2976
|
+
const account = npm?.auth?.account ?? null
|
|
2977
|
+
const registry = String(npm?.registry ?? '')
|
|
2978
|
+
const npmrcPath = String(npm?.auth?.npmrcPath ?? '')
|
|
2979
|
+
const hasToken = npm?.auth?.npmrcHasToken === true
|
|
2980
|
+
const proven = account !== null
|
|
2981
|
+
const status = (kind, text) => h(Chip, { state: kind === 'done' ? 'success' : kind === 'todo' ? 'warn' : 'idle' }, text)
|
|
2982
|
+
return h(
|
|
2983
|
+
React.Fragment,
|
|
2984
|
+
null,
|
|
2985
|
+
h(
|
|
2986
|
+
'div',
|
|
2987
|
+
{ className: 'dsc-setup-line' },
|
|
2988
|
+
h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step1')),
|
|
2989
|
+
proven
|
|
2990
|
+
? status('done', t('npm.stepAccountDone', { account: String(account) }))
|
|
2991
|
+
: status('unknown', t('npm.stepUnknown')),
|
|
2992
|
+
),
|
|
2993
|
+
h(
|
|
2994
|
+
'div',
|
|
2995
|
+
{ className: 'dsc-setup-line' },
|
|
2996
|
+
h(Btn, { onClick: () => globalThis.open(NPM_SIGNUP_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.signup')),
|
|
2997
|
+
),
|
|
2998
|
+
h('span', null, t('npm.guide.emailNote')),
|
|
2999
|
+
|
|
3000
|
+
h(
|
|
3001
|
+
'div',
|
|
3002
|
+
{ className: 'dsc-setup-line' },
|
|
3003
|
+
h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step2')),
|
|
3004
|
+
proven ? status('done', t('npm.stepTokenDone')) : status('unknown', t('npm.stepUnknown')),
|
|
3005
|
+
),
|
|
3006
|
+
h(
|
|
3007
|
+
'div',
|
|
3008
|
+
{ className: 'dsc-setup-line' },
|
|
3009
|
+
h(Btn, { onClick: () => globalThis.open(NPM_TOKENS_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.tokens')),
|
|
3010
|
+
h(Btn, { kind: 'quiet', onClick: () => globalThis.open(NPM_TOKEN_DOCS_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.docs')),
|
|
3011
|
+
),
|
|
3012
|
+
h('span', null, t('npm.guide.tokenType')),
|
|
3013
|
+
h('span', null, t('npm.guide.tokenScope')),
|
|
3014
|
+
h('span', null, t('npm.guide.tokenExpiry')),
|
|
3015
|
+
h('span', null, t('npm.guide.token2fa')),
|
|
3016
|
+
/* The step that is not on this machine: npmjs.com sits behind a bot challenge
|
|
3017
|
+
that answers whole-site 403s to some networks, and the token does not care
|
|
3018
|
+
where it was minted. Saying so here is the difference between a five-minute
|
|
3019
|
+
workaround and an afternoon spent on the wrong problem. */
|
|
3020
|
+
h('span', { className: 'dsc-quiet' }, t('npm.guide.blocked')),
|
|
3021
|
+
|
|
3022
|
+
h(
|
|
3023
|
+
'div',
|
|
3024
|
+
{ className: 'dsc-setup-line' },
|
|
3025
|
+
h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step3')),
|
|
3026
|
+
hasToken ? status('done', t('npm.stepWrittenDone', { npmrc: npmrcPath })) : status('todo', t('npm.stepTodo')),
|
|
3027
|
+
),
|
|
3028
|
+
h(NpmTokenLine, { t, credential, busy: props.busy }),
|
|
3029
|
+
h('span', null, t('npm.guide.step3Note', { npmrc: npmrcPath })),
|
|
3030
|
+
h(NpmRejectedNote, { t, rejected: credential.rejected }),
|
|
3031
|
+
|
|
3032
|
+
h(
|
|
3033
|
+
'div',
|
|
3034
|
+
{ className: 'dsc-setup-line' },
|
|
3035
|
+
h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step4')),
|
|
3036
|
+
status('unknown', t('npm.stepUnknown')),
|
|
3037
|
+
),
|
|
3038
|
+
h('span', null, t('npm.guide.step4Note')),
|
|
3039
|
+
|
|
3040
|
+
/* What the package manager said, verbatim: "not signed in" and "that token was
|
|
3041
|
+
revoked" are the same blank state and different problems. */
|
|
3042
|
+
npm?.auth?.message !== null && npm?.auth?.message !== undefined
|
|
3043
|
+
? h('span', { className: 'dsc-mono' }, String(npm.auth.message))
|
|
3044
|
+
: null,
|
|
3045
|
+
h('span', { className: 'dsc-quiet' }, t('npm.ciHint')),
|
|
3046
|
+
)
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
/**
|
|
3050
|
+
* The Settings page for npm credentials: the guide, and every package's state.
|
|
3051
|
+
*
|
|
3052
|
+
* The panel answers "which repository needs attention"; this page answers "can
|
|
3053
|
+
* this machine publish at all, and which of my packages are already up there" —
|
|
3054
|
+
* which is configuration, not operation, and belongs where the GitHub account
|
|
3055
|
+
* page already lives.
|
|
3056
|
+
*/
|
|
3057
|
+
function NpmCredentialsPage(props) {
|
|
3058
|
+
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
3059
|
+
const { npm, error, setError, loadNpm } = useConsoleState()
|
|
3060
|
+
const [busy, setBusy] = React.useState('')
|
|
3061
|
+
const [notice, setNotice] = React.useState(null)
|
|
3062
|
+
const credential = useNpmCredential(t, loadNpm, setError, setNotice)
|
|
3063
|
+
|
|
3064
|
+
React.useEffect(() => {
|
|
3065
|
+
void loadNpm({ force: true })
|
|
3066
|
+
}, [loadNpm])
|
|
3067
|
+
|
|
3068
|
+
const auth = npm?.auth ?? null
|
|
3069
|
+
const state = auth === null ? 'unknown' : String(auth.state ?? (auth.loggedIn === true ? 'signed-in' : 'none'))
|
|
3070
|
+
|
|
3071
|
+
const recheck = React.useCallback(async () => {
|
|
3072
|
+
setBusy('recheck')
|
|
3073
|
+
await loadNpm({ force: true })
|
|
3074
|
+
setBusy('')
|
|
3075
|
+
}, [loadNpm])
|
|
3076
|
+
|
|
3077
|
+
const packageState = (entry) => {
|
|
3078
|
+
if (entry.state === 'published') return { kind: 'success', text: t('npm.package.published') }
|
|
3079
|
+
if (entry.state === 'unregistered') return { kind: 'warn', text: t('npm.package.unregistered') }
|
|
3080
|
+
if (entry.state === 'unpublished') return { kind: 'warn', text: t('npm.package.unpublished') }
|
|
3081
|
+
return { kind: 'idle', text: t('npm.package.unknown') }
|
|
3082
|
+
}
|
|
3083
|
+
const blockerText = (entry) => {
|
|
3084
|
+
const first = Array.isArray(entry.blockers) ? entry.blockers[0] : null
|
|
3085
|
+
if (first === null) return null
|
|
3086
|
+
const key = `npm.blocked.${first}`
|
|
3087
|
+
const text = t(key)
|
|
3088
|
+
return text === key ? null : text
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
return h(
|
|
3092
|
+
'div',
|
|
3093
|
+
{ className: 'dsc-root' },
|
|
3094
|
+
h(
|
|
3095
|
+
'div',
|
|
3096
|
+
{ className: 'dsc-bar' },
|
|
3097
|
+
h(
|
|
3098
|
+
'div',
|
|
3099
|
+
{ className: 'dsc-heading' },
|
|
3100
|
+
h('span', { className: 'dsc-title' }, t('settings.npmTitle')),
|
|
3101
|
+
h('span', { className: 'dsc-subtitle' }, t('settings.npmSubtitle')),
|
|
3102
|
+
),
|
|
3103
|
+
h(Btn, { disabled: busy !== '', onClick: () => { void recheck() } }, busy === 'recheck' ? '…' : t('npm.packages.recheck')),
|
|
3104
|
+
),
|
|
3105
|
+
|
|
3106
|
+
error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
|
|
3107
|
+
notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
|
|
3108
|
+
|
|
3109
|
+
npm === null
|
|
3110
|
+
? h('div', { className: 'dsc-setup' }, h('span', { className: 'dsc-setup-strong' }, t('npm.status.unknown')), h('span', null, t('npm.status.unknownHint')))
|
|
3111
|
+
: h(
|
|
3112
|
+
React.Fragment,
|
|
3113
|
+
null,
|
|
3114
|
+
h(
|
|
3115
|
+
'div',
|
|
3116
|
+
{ className: 'dsc-setup-line' },
|
|
3117
|
+
h('span', { className: 'dsc-setup-strong' }, t('npm.status.title')),
|
|
3118
|
+
state === 'signed-in'
|
|
3119
|
+
? h(Chip, { state: 'success' }, t('npm.status.signedIn', { account: String(auth.account ?? '?') }))
|
|
3120
|
+
: state === 'credential-present'
|
|
3121
|
+
? h(Chip, { state: 'idle' }, t('npm.status.credentialPresent'))
|
|
3122
|
+
: h(Chip, { state: 'warn' }, t('npm.status.none')),
|
|
3123
|
+
h('span', { className: 'dsc-mono' }, t('npm.registry', { registry: String(npm.registry ?? '') })),
|
|
3124
|
+
),
|
|
3125
|
+
/* "Signed in" and "a credential that whoami will not confirm" are the same
|
|
3126
|
+
blank state on screen and different situations, so this page spells the
|
|
3127
|
+
second one out instead of collapsing it into the first. */
|
|
3128
|
+
state === 'credential-present'
|
|
3129
|
+
? h('span', null, t('npm.credentialPresent', { registry: String(npm.registry ?? ''), npmrc: String(auth.npmrcPath ?? '') }))
|
|
3130
|
+
: null,
|
|
3131
|
+
/* The guide is the page's reason to exist, so it is always here — the
|
|
3132
|
+
panel is where it would be in the way. */
|
|
3133
|
+
h(
|
|
3134
|
+
'div',
|
|
3135
|
+
{ className: 'dsc-setup' },
|
|
3136
|
+
h('span', { className: 'dsc-setup-strong' }, t('settings.npmGuideTitle')),
|
|
3137
|
+
h(NpmCredentialGuide, { t, npm, credential, busy }),
|
|
3138
|
+
),
|
|
3139
|
+
h(
|
|
3140
|
+
'div',
|
|
3141
|
+
{ className: 'dsc-sub' },
|
|
3142
|
+
h('span', { className: 'dsc-setup-strong' }, t('npm.packages.title')),
|
|
3143
|
+
(npm.repos ?? []).length === 0
|
|
3144
|
+
? h('span', null, t('npm.packages.empty'))
|
|
3145
|
+
: (npm.repos ?? []).map((entry) =>
|
|
3146
|
+
h(
|
|
3147
|
+
'div',
|
|
3148
|
+
{ className: 'dsc-line', key: entry.repo },
|
|
3149
|
+
h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(entry.packageName ?? entry.repo)),
|
|
3150
|
+
h(Chip, { state: packageState(entry).kind }, packageState(entry).text),
|
|
3151
|
+
h('span', { className: 'dsc-grow' }, `${t('npm.package.local', { version: String(entry.version ?? '?') })}${entry.latest === null || entry.latest === undefined ? '' : ` · ${t('npm.package.latest', { latest: String(entry.latest) })}`}`),
|
|
3152
|
+
blockerText(entry) === null ? null : h('span', { className: 'dsc-quiet' }, blockerText(entry)),
|
|
3153
|
+
entry.pageUrl ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(entry.pageUrl), '_blank', 'noopener,noreferrer') }, t('action.open')) : null,
|
|
3154
|
+
),
|
|
3155
|
+
),
|
|
3156
|
+
),
|
|
3157
|
+
),
|
|
3158
|
+
)
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
/**
|
|
3162
|
+
* The Bilibili credential, in one component used in two places.
|
|
3163
|
+
*
|
|
3164
|
+
* The panel shows it only when it is the obstacle (a video is bound and the
|
|
3165
|
+
* credential cannot post); the settings page shows it always, because that is
|
|
3166
|
+
* where a credential is configured. One component, so the two cannot drift
|
|
3167
|
+
* into two different explanations of the same `-101`.
|
|
3168
|
+
*/
|
|
3169
|
+
function BilibiliSignIn(props) {
|
|
3170
|
+
const { t, bili, busy, setBusy, setNotice, setError, onChanged } = props
|
|
3171
|
+
const credential = bili?.credential ?? null
|
|
3172
|
+
const login = bili?.login ?? { state: 'idle' }
|
|
3173
|
+
const [sessdata, setSessdata] = React.useState('')
|
|
3174
|
+
const [csrf, setCsrf] = React.useState('')
|
|
3175
|
+
const [confirmingSignOut, setConfirmingSignOut] = React.useState(false)
|
|
3176
|
+
|
|
3177
|
+
/** One request, with the two failures this page has to tell apart. */
|
|
3178
|
+
const post = React.useCallback(async (key, path, body) => {
|
|
3179
|
+
setBusy(key)
|
|
3180
|
+
setError(null)
|
|
3181
|
+
const result = await postJson(path, body, ACTION_TIMEOUT_MS)
|
|
3182
|
+
setBusy('')
|
|
3183
|
+
if (!result.ok) {
|
|
3184
|
+
setError(result.aborted ? 'timeout' : 'host')
|
|
3185
|
+
return null
|
|
3186
|
+
}
|
|
3187
|
+
if (!result.response.ok || result.payload?.ok !== true) {
|
|
3188
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
3189
|
+
await onChanged()
|
|
3190
|
+
return null
|
|
3191
|
+
}
|
|
3192
|
+
return result.payload.value ?? {}
|
|
3193
|
+
}, [onChanged, setBusy, setError, t])
|
|
3194
|
+
|
|
3195
|
+
const start = React.useCallback(async () => {
|
|
3196
|
+
const value = await post('bili-login', '/bilibili-login-start', {})
|
|
3197
|
+
if (value !== null) await onChanged()
|
|
3198
|
+
}, [onChanged, post])
|
|
3199
|
+
|
|
3200
|
+
const cancel = React.useCallback(async () => {
|
|
3201
|
+
await post('bili-cancel', '/bilibili-login-cancel', {})
|
|
3202
|
+
await onChanged()
|
|
3203
|
+
}, [onChanged, post])
|
|
3204
|
+
|
|
3205
|
+
const save = React.useCallback(async () => {
|
|
3206
|
+
const value = await post('bili-paste', '/bilibili-credential', { sessdata, bili_jct: csrf })
|
|
3207
|
+
setSessdata('')
|
|
3208
|
+
setCsrf('')
|
|
3209
|
+
if (value === null) return
|
|
3210
|
+
setNotice(t('bili.paste.saved', { uname: String(value.account?.uname ?? '') }))
|
|
3211
|
+
await onChanged()
|
|
3212
|
+
}, [csrf, onChanged, post, sessdata, setNotice, t])
|
|
3213
|
+
|
|
3214
|
+
const signOut = React.useCallback(async () => {
|
|
3215
|
+
if (confirmingSignOut !== true) {
|
|
3216
|
+
setConfirmingSignOut(true)
|
|
3217
|
+
return
|
|
3218
|
+
}
|
|
3219
|
+
setConfirmingSignOut(false)
|
|
3220
|
+
const value = await post('bili-signout', '/bilibili-logout', {})
|
|
3221
|
+
if (value === null) return
|
|
3222
|
+
/* An external credential keeps working after this one is deleted, and
|
|
3223
|
+
reporting a sign-out that did not happen would be a lie the next sweep
|
|
3224
|
+
exposes anyway. */
|
|
3225
|
+
setNotice(value.stillAvailable ? t('bili.signOut.fallback', { path: String(value.stillAvailable.path ?? '') }) : t('bili.signOut.done'))
|
|
3226
|
+
await onChanged()
|
|
3227
|
+
}, [confirmingSignOut, onChanged, post, setNotice, t])
|
|
3228
|
+
|
|
3229
|
+
const running = login.state === 'waiting' || login.state === 'scanned'
|
|
3230
|
+
/* Encoded once per URL: the panel re-renders on every poll, and building the
|
|
3231
|
+
matrix is the only expensive thing on this screen. */
|
|
3232
|
+
const qr = React.useMemo(
|
|
3233
|
+
() => (typeof login.url === 'string' && login.url !== '' ? qrFor(login.url) : null),
|
|
3234
|
+
[login.url],
|
|
3235
|
+
)
|
|
3236
|
+
React.useEffect(() => {
|
|
3237
|
+
if (!running) return undefined
|
|
3238
|
+
let cancelled = false
|
|
3239
|
+
const tick = async () => {
|
|
3240
|
+
const result = await postJson('/bilibili-login-poll', {}, STATUS_TIMEOUT_MS)
|
|
3241
|
+
if (cancelled) return
|
|
3242
|
+
const value = result.payload?.value ?? {}
|
|
3243
|
+
if (result.ok && result.response.ok && result.payload?.ok === true) {
|
|
3244
|
+
if (value.state === 'succeeded') {
|
|
3245
|
+
setNotice(value.account?.uname
|
|
3246
|
+
? t('bili.signIn.done', { uname: String(value.account.uname) })
|
|
3247
|
+
: t('bili.signIn.doneNoName'))
|
|
3248
|
+
}
|
|
3249
|
+
if (value.state === 'expired') setError(t('bili.signIn.expired'))
|
|
3250
|
+
} else if (result.ok && !result.aborted) {
|
|
3251
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
3252
|
+
}
|
|
3253
|
+
await onChanged()
|
|
3254
|
+
}
|
|
3255
|
+
const timer = globalThis.setInterval(() => { void tick() }, AUTH_POLL_MS)
|
|
3256
|
+
return () => {
|
|
3257
|
+
cancelled = true
|
|
3258
|
+
globalThis.clearInterval(timer)
|
|
3259
|
+
}
|
|
3260
|
+
}, [onChanged, running, setError, setNotice, t])
|
|
3261
|
+
|
|
3262
|
+
if (credential === null) return null
|
|
3263
|
+
const state = String(credential.state ?? 'none')
|
|
3264
|
+
/**
|
|
3265
|
+
* A credential that answered the account endpoint. Everything below that exists
|
|
3266
|
+
* to GET one — the scan prompt, the paste field and their hints — is pointless
|
|
3267
|
+
* once there is one, and showing it anyway makes the page argue with its own
|
|
3268
|
+
* status line. The only action left is to stop being signed in.
|
|
3269
|
+
*/
|
|
3270
|
+
const ready = state === 'ready'
|
|
3271
|
+
const stateText = (() => {
|
|
3272
|
+
if (state === 'ready') {
|
|
3273
|
+
return credential.account?.uname
|
|
3274
|
+
? t('bili.credential.ready', { uname: String(credential.account.uname) })
|
|
3275
|
+
: t('bili.credential.ready', { uname: '?' })
|
|
3276
|
+
}
|
|
3277
|
+
const key = `bili.credential.${state}`
|
|
3278
|
+
const text = t(key)
|
|
3279
|
+
return text === key ? t('bili.credential.unverified') : text
|
|
3280
|
+
})()
|
|
3281
|
+
|
|
3282
|
+
return h(
|
|
3283
|
+
React.Fragment,
|
|
3284
|
+
null,
|
|
3285
|
+
h(
|
|
3286
|
+
'div',
|
|
3287
|
+
{ className: 'dsc-setup', role: 'status' },
|
|
3288
|
+
h(
|
|
3289
|
+
'div',
|
|
3290
|
+
{ className: 'dsc-setup-line' },
|
|
3291
|
+
h('span', { className: 'dsc-setup-strong' }, t('bili.credential.title')),
|
|
3292
|
+
h(Chip, { state: state === 'ready' ? 'success' : state === 'none' ? 'warn' : 'idle' }, stateText),
|
|
3293
|
+
credential.path
|
|
3294
|
+
? h('span', { className: 'dsc-mono', title: String(credential.path) }, t('bili.credential.source', { path: String(credential.path) }))
|
|
3295
|
+
: null,
|
|
3296
|
+
),
|
|
3297
|
+
/* Why it cannot be used, in Bilibili's own terms. A bare "-101" sends the
|
|
3298
|
+
reader to re-login in the wrong place when the credential is an APP one. */
|
|
3299
|
+
state !== 'ready' && credential.message ? h('span', { className: 'dsc-warn' }, String(credential.message)) : null,
|
|
3300
|
+
state !== 'ready' ? h('span', null, t('bili.credential.none.hint')) : null,
|
|
3301
|
+
credential.source === 'configured'
|
|
3302
|
+
? h('span', { className: 'dsc-quiet' }, t('bili.credential.external', { path: String(credential.configuredPath ?? '') }))
|
|
3303
|
+
: null,
|
|
3304
|
+
|
|
3305
|
+
/*
|
|
3306
|
+
* The ways to OBTAIN a credential are for not having one, and a signed-in page
|
|
3307
|
+
* that still says 登录 B 站 — directly under 已登录:<name> — is the panel
|
|
3308
|
+
* contradicting itself. It did exactly that: this block was gated on "is a
|
|
3309
|
+
* sign-in running", never on the credential, so a fresh sign-in left the
|
|
3310
|
+
* invitation to sign in again on screen as if nothing had happened.
|
|
3311
|
+
*
|
|
3312
|
+
* `running` still wins, because a sign-in that is mid-flight should show its
|
|
3313
|
+
* code even in the instant before the credential lands.
|
|
3314
|
+
*/
|
|
3315
|
+
ready
|
|
3316
|
+
? null
|
|
3317
|
+
: running
|
|
3318
|
+
? h(
|
|
3319
|
+
React.Fragment,
|
|
3320
|
+
null,
|
|
3321
|
+
/*
|
|
3322
|
+
* The code itself. The sign-in hands back a URL whose whole purpose is
|
|
3323
|
+
* to be SCANNED, and drawing it here is what makes "scan it with the
|
|
3324
|
+
* phone" true rather than merely stated.
|
|
3325
|
+
*/
|
|
3326
|
+
qr === null
|
|
3327
|
+
? h('span', { className: 'dsc-quiet' }, login.url ? t('bili.qrTooLong') : null)
|
|
3328
|
+
: h(
|
|
3329
|
+
'div',
|
|
3330
|
+
{ className: 'dsc-scan' },
|
|
3331
|
+
h(QrCode, { code: qr, label: t('bili.qrLabel') }),
|
|
3332
|
+
h('span', { className: 'dsc-quiet' }, t('bili.qrHint')),
|
|
3333
|
+
),
|
|
3334
|
+
h(
|
|
3335
|
+
'div',
|
|
3336
|
+
{ className: 'dsc-setup-line' },
|
|
3337
|
+
h('span', { className: 'dsc-grow' }, login.state === 'scanned' ? t('bili.waitingScanned') : t('bili.waiting')),
|
|
3338
|
+
/*
|
|
3339
|
+
* No "open the sign-in page" button. That URL is the H5 page for a
|
|
3340
|
+
* PHONE, and opening it in a desktop browser offers the Bilibili app's
|
|
3341
|
+
* APK instead of a sign-in — a download, not a login. The code above is
|
|
3342
|
+
* the way through; the paste route below is the fallback.
|
|
3343
|
+
*/
|
|
3344
|
+
h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void cancel() } }, t('bili.cancelSignIn')),
|
|
3345
|
+
),
|
|
3346
|
+
)
|
|
3347
|
+
: h(
|
|
3348
|
+
'div',
|
|
3349
|
+
{ className: 'dsc-setup-line' },
|
|
3350
|
+
h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void start() } }, busy === 'bili-login' ? '…' : t('bili.signIn')),
|
|
3351
|
+
h('span', { className: 'dsc-quiet' }, t('bili.signIn.hint')),
|
|
3352
|
+
),
|
|
3353
|
+
/* Signed in: the one action left is to stop being signed in, which is also how
|
|
3354
|
+
someone switches accounts. */
|
|
3355
|
+
ready
|
|
3356
|
+
? h(
|
|
3357
|
+
'div',
|
|
3358
|
+
{ className: 'dsc-setup-line' },
|
|
3359
|
+
h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void signOut() } }, confirmingSignOut ? t('bili.signOut.confirm') : t('bili.signOut')),
|
|
3360
|
+
)
|
|
3361
|
+
: null,
|
|
3362
|
+
|
|
3363
|
+
ready
|
|
3364
|
+
? null
|
|
3365
|
+
: h(
|
|
3366
|
+
React.Fragment,
|
|
3367
|
+
null,
|
|
3368
|
+
h('span', { className: 'dsc-setup-strong' }, t('bili.paste.title')),
|
|
3369
|
+
h('span', { className: 'dsc-quiet' }, t('bili.paste.hint')),
|
|
3370
|
+
),
|
|
3371
|
+
ready
|
|
3372
|
+
? null
|
|
3373
|
+
: h(
|
|
3374
|
+
'div',
|
|
3375
|
+
{ className: 'dsc-setup-line' },
|
|
3376
|
+
h('input', {
|
|
3377
|
+
className: 'dsc-input',
|
|
3378
|
+
type: 'password',
|
|
3379
|
+
autoComplete: 'off',
|
|
3380
|
+
spellCheck: 'false',
|
|
3381
|
+
placeholder: t('bili.paste.sessdata'),
|
|
3382
|
+
'aria-label': t('bili.paste.sessdata'),
|
|
3383
|
+
value: sessdata,
|
|
3384
|
+
onChange: (event) => setSessdata(String(event?.target?.value ?? '')),
|
|
3385
|
+
}),
|
|
3386
|
+
h('input', {
|
|
3387
|
+
className: 'dsc-input',
|
|
3388
|
+
type: 'password',
|
|
3389
|
+
autoComplete: 'off',
|
|
3390
|
+
spellCheck: 'false',
|
|
3391
|
+
placeholder: t('bili.paste.csrf'),
|
|
3392
|
+
'aria-label': t('bili.paste.csrf'),
|
|
3393
|
+
value: csrf,
|
|
3394
|
+
onChange: (event) => setCsrf(String(event?.target?.value ?? '')),
|
|
3395
|
+
}),
|
|
3396
|
+
h(Btn, {
|
|
3397
|
+
kind: 'primary',
|
|
3398
|
+
disabled: busy !== '' || sessdata.trim() === '' || csrf.trim() === '',
|
|
3399
|
+
onClick: () => { void save() },
|
|
3400
|
+
}, busy === 'bili-paste' ? '…' : t('bili.paste.save')),
|
|
3401
|
+
),
|
|
3402
|
+
),
|
|
3403
|
+
)
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* Where a working tree would go, and the button that fetches one.
|
|
3408
|
+
*
|
|
3409
|
+
* A repository can be registered without being cloned, and then every action this
|
|
3410
|
+
* console offers is unavailable for it — 提交 has nothing to commit, 构建 and 发布
|
|
3411
|
+
* act on what is on GitHub rather than on this disk, and the whole local column
|
|
3412
|
+
* reads as broken. So the panel answers the one question that unblocks all of that:
|
|
3413
|
+
* where is it, and shall I fetch it.
|
|
3414
|
+
*
|
|
3415
|
+
* The address comes pre-filled from the Host, which derives it from the repository
|
|
3416
|
+
* name (`cloneUrl`) — being asked to paste the clone URL of a repository the Host
|
|
3417
|
+
* can already name is exactly the kind of step this console exists to remove. It
|
|
3418
|
+
* stays editable, because the derivation is a convenience and not a fact: a fork, a
|
|
3419
|
+
* private remote or a second remote is a real thing.
|
|
3420
|
+
*/
|
|
3421
|
+
function CloneCheckout(props) {
|
|
3422
|
+
const { t, data, local, busy, onClone } = props
|
|
3423
|
+
const [url, setUrl] = React.useState(String(data.cloneUrl ?? ''))
|
|
3424
|
+
React.useEffect(() => {
|
|
3425
|
+
setUrl(String(data.cloneUrl ?? ''))
|
|
3426
|
+
}, [data.cloneUrl])
|
|
3427
|
+
|
|
3428
|
+
const root = String(data.checkoutRoot ?? '')
|
|
3429
|
+
const name = String(data.repo ?? '').split('/').pop()
|
|
3430
|
+
const separator = root.endsWith('/') || root.endsWith('\\') ? '' : '\\'
|
|
3431
|
+
const target = root === '' ? '' : `${root}${separator}${name}`
|
|
3432
|
+
const why = data.localPath === ''
|
|
3433
|
+
? t('clone.why.none')
|
|
3434
|
+
: t('clone.why.broken', { path: String(data.localPath), reason: String(local?.reason ?? '') })
|
|
3435
|
+
|
|
3436
|
+
return h(
|
|
3437
|
+
'div',
|
|
3438
|
+
{ className: 'dsc-sub' },
|
|
3439
|
+
h('span', { className: 'dsc-setup-strong' }, t('clone.title')),
|
|
3440
|
+
h('span', { className: 'dsc-quiet' }, why),
|
|
3441
|
+
h(
|
|
3442
|
+
'div',
|
|
3443
|
+
{ className: 'dsc-line' },
|
|
3444
|
+
h('input', {
|
|
3445
|
+
className: 'dsc-input',
|
|
3446
|
+
type: 'text',
|
|
3447
|
+
spellCheck: 'false',
|
|
3448
|
+
placeholder: t('clone.urlPlaceholder'),
|
|
3449
|
+
'aria-label': t('clone.urlPlaceholder'),
|
|
3450
|
+
value: url,
|
|
3451
|
+
onChange: (event) => setUrl(String(event?.target?.value ?? '').trim()),
|
|
3452
|
+
}),
|
|
3453
|
+
h(Btn, {
|
|
3454
|
+
kind: 'primary',
|
|
3455
|
+
disabled: busy !== '' || url === '' || root === '',
|
|
3456
|
+
onClick: () => onClone(data, url),
|
|
3457
|
+
}, busy === `clone:${data.repo}` ? '…' : t('clone.action')),
|
|
3458
|
+
),
|
|
3459
|
+
/* Set where it will land, or say what is missing to be able to answer that at
|
|
3460
|
+
all. A clone that silently picks a directory is a clone someone has to go and
|
|
3461
|
+
find afterwards. */
|
|
3462
|
+
root === ''
|
|
3463
|
+
? h('span', { className: 'dsc-warn' }, t('clone.noRoot'))
|
|
3464
|
+
: h('span', { className: 'dsc-quiet dsc-mono' }, t('clone.target', { path: target })),
|
|
3465
|
+
)
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* One repository's video binding, inside its row.
|
|
3470
|
+
*
|
|
3471
|
+
* The binding is edited here because this is where the question is asked: the
|
|
3472
|
+
* row already knows what was released and whether the profile has it, and "who
|
|
3473
|
+
* reads about it" is the same repository's business. The announcement itself is
|
|
3474
|
+
* always previewed first — the comment is public, and the sentence shown is the
|
|
3475
|
+
* sentence the Host would send, composed on the Host.
|
|
3476
|
+
*/
|
|
3477
|
+
function BiliBinding(props) {
|
|
3478
|
+
const { t, data, bili, busy, onBind, onPreview, onAnnounce } = props
|
|
3479
|
+
const binding = bili?.binding ?? null
|
|
3480
|
+
const [draft, setDraft] = React.useState(binding === null ? '' : String(binding.bvid))
|
|
3481
|
+
const [preview, setPreview] = React.useState(null)
|
|
3482
|
+
|
|
3483
|
+
/* The Host is the source of truth for the binding: after a save the field
|
|
3484
|
+
shows what was actually stored, not what was typed. */
|
|
3485
|
+
React.useEffect(() => {
|
|
3486
|
+
setDraft(binding === null ? '' : String(binding.bvid))
|
|
3487
|
+
}, [binding === null ? '' : String(binding.bvid)])
|
|
3488
|
+
|
|
3489
|
+
const announced = Array.isArray(bili?.announced) ? bili.announced : []
|
|
3490
|
+
const last = announced.length > 0 ? announced[announced.length - 1] : null
|
|
3491
|
+
const failures = Array.isArray(bili?.failures) ? bili.failures : []
|
|
3492
|
+
const failure = failures.length > 0 ? failures[failures.length - 1] : null
|
|
3493
|
+
/**
|
|
3494
|
+
* Why the Host would not post this one, in the reader's language where the
|
|
3495
|
+
* dictionary knows the state, and in the Host's own words otherwise. The Host
|
|
3496
|
+
* composes its refusals in one language; a known state deserves the other.
|
|
3497
|
+
*/
|
|
3498
|
+
const previewReason = (value) => {
|
|
3499
|
+
const key = `bili.state.${String(value?.state ?? '')}`
|
|
3500
|
+
const text = t(key, { count: String(value?.attempts ?? '') })
|
|
3501
|
+
return text === key ? String(value?.message ?? '') : text
|
|
3502
|
+
}
|
|
3503
|
+
/**
|
|
3504
|
+
* What would be announced if the sweep ran now, as far as the panel can tell.
|
|
3505
|
+
* The decision itself is the Host's; this is a chip, and it stays silent when
|
|
3506
|
+
* the baseline is unknown rather than guessing at the one case that matters.
|
|
3507
|
+
*/
|
|
3508
|
+
const pendingTag = (() => {
|
|
3509
|
+
if (binding === null) return null
|
|
3510
|
+
const tag = typeof data.publishedTag === 'string' && data.publishedTag !== '' ? data.publishedTag : null
|
|
3511
|
+
if (tag === null) return null
|
|
3512
|
+
if (announced.some((entry) => entry.tag === tag)) return null
|
|
3513
|
+
if (bili?.baseline === null || bili?.baseline === undefined) return null
|
|
3514
|
+
if (bili.baseline.tag === null) return null
|
|
3515
|
+
if (bili.baseline.tag === tag) return null
|
|
3516
|
+
return tag
|
|
3517
|
+
})()
|
|
3518
|
+
|
|
3519
|
+
return h(
|
|
3520
|
+
'div',
|
|
3521
|
+
{ className: 'dsc-sub' },
|
|
3522
|
+
h('span', null, t('bili.bind.title')),
|
|
3523
|
+
h(
|
|
3524
|
+
'div',
|
|
3525
|
+
{ className: 'dsc-line' },
|
|
3526
|
+
/*
|
|
3527
|
+
* Bound is a state, not a draft. Editing a live binding in place would change
|
|
3528
|
+
* which video a repository announces under, with the announcement history
|
|
3529
|
+
* still pointing at the old one — so the field is only editable while there is
|
|
3530
|
+
* nothing bound, and once there is, the single action left is 解绑. A wrong BV
|
|
3531
|
+
* is therefore fixed by untying it first, which is also the only way the ledger
|
|
3532
|
+
* and the binding stay consistent with each other.
|
|
3533
|
+
*/
|
|
3534
|
+
binding === null
|
|
3535
|
+
? h('input', {
|
|
3536
|
+
className: 'dsc-input',
|
|
3537
|
+
type: 'text',
|
|
3538
|
+
spellCheck: 'false',
|
|
3539
|
+
placeholder: t('bili.bind.placeholder'),
|
|
3540
|
+
'aria-label': t('bili.bind.placeholder'),
|
|
3541
|
+
value: draft,
|
|
3542
|
+
onChange: (event) => setDraft(String(event?.target?.value ?? '').trim()),
|
|
3543
|
+
})
|
|
3544
|
+
: h('span', { className: 'dsc-mono dsc-bound' }, String(binding.bvid)),
|
|
3545
|
+
binding === null
|
|
3546
|
+
? h(Btn, {
|
|
3547
|
+
kind: 'primary',
|
|
3548
|
+
disabled: busy !== '' || draft.trim() === '',
|
|
3549
|
+
onClick: () => { void onBind(data, draft.trim(), true) },
|
|
3550
|
+
}, busy === `bili-bind:${data.repo}` ? '…' : t('bili.bind.save'))
|
|
3551
|
+
: h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void onBind(data, '', true) } }, t('bili.bind.unbind')),
|
|
3552
|
+
binding === null ? null : h('span', { className: 'dsc-quiet' }, t('bili.bind.locked')),
|
|
3553
|
+
h('span', { className: 'dsc-grow' }, binding === null
|
|
3554
|
+
? t('bili.repo.none')
|
|
3555
|
+
: bili?.video?.ok === true
|
|
3556
|
+
? `${String(bili.video.title ?? '')}${bili.video.owner ? ` · ${String(bili.video.owner)}` : ''}`
|
|
3557
|
+
: t('bili.video.failed', { reason: String(bili?.video?.message ?? '?') })),
|
|
3558
|
+
),
|
|
3559
|
+
binding !== null
|
|
3560
|
+
? h(
|
|
3561
|
+
'div',
|
|
3562
|
+
{ className: 'dsc-line' },
|
|
3563
|
+
h(Chip, { state: binding.auto === false ? 'idle' : 'success' }, binding.auto === false ? t('bili.auto.off') : t('bili.auto.on')),
|
|
3564
|
+
h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void onBind(data, binding.bvid, binding.auto === false) } }, t('bili.auto.toggle')),
|
|
3565
|
+
h('span', { className: 'dsc-grow' }, last === null
|
|
3566
|
+
? t('bili.announced.none')
|
|
3567
|
+
: `${t('bili.chip.announced', { tag: String(last.tag) })}${last.url ? '' : ''}`),
|
|
3568
|
+
pendingTag !== null ? h(Chip, { state: 'warn' }, t('bili.chip.pending', { tag: pendingTag })) : null,
|
|
3569
|
+
/* The comment exists as far as Bilibili is concerned, and not as far as a
|
|
3570
|
+
reader is — a distinction the ledger now records and the panel used to
|
|
3571
|
+
hide behind "已播报". */
|
|
3572
|
+
last !== null && last.visible === false
|
|
3573
|
+
? h(Chip, { state: 'warn', title: t('bili.visible.hint', { code: String(last.visibleCode ?? '?') }) }, t('bili.visible.false'))
|
|
3574
|
+
: null,
|
|
3575
|
+
last !== null && typeof last.url === 'string' && last.url !== ''
|
|
3576
|
+
? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(last.url), '_blank', 'noopener,noreferrer') }, t('action.open'))
|
|
3577
|
+
: null,
|
|
3578
|
+
)
|
|
3579
|
+
: null,
|
|
3580
|
+
failure !== null
|
|
3581
|
+
? h('span', { className: 'dsc-warn' }, t('bili.failure.last', { message: String(failure.message ?? failure.failure ?? '') }))
|
|
3582
|
+
: null,
|
|
3583
|
+
binding !== null
|
|
3584
|
+
? h(
|
|
3585
|
+
'div',
|
|
3586
|
+
{ className: 'dsc-line' },
|
|
3587
|
+
h(Btn, {
|
|
3588
|
+
kind: 'primary',
|
|
3589
|
+
disabled: busy !== '',
|
|
3590
|
+
onClick: () => {
|
|
3591
|
+
void (async () => {
|
|
3592
|
+
const value = await onPreview(data)
|
|
3593
|
+
if (value !== null) setPreview(value)
|
|
3594
|
+
})()
|
|
3595
|
+
},
|
|
3596
|
+
}, busy === `bili-preview:${data.repo}` ? '…' : (announced.length > 0 ? t('bili.announce.again') : t('bili.announce'))),
|
|
3597
|
+
)
|
|
3598
|
+
: null,
|
|
3599
|
+
preview !== null
|
|
3600
|
+
? h(
|
|
3601
|
+
React.Fragment,
|
|
3602
|
+
null,
|
|
3603
|
+
h('span', { className: 'dsc-setup-strong' }, t('bili.announce.preview')),
|
|
3604
|
+
preview.state !== 'ready'
|
|
3605
|
+
? h('span', { className: 'dsc-warn' }, previewReason(preview))
|
|
3606
|
+
: null,
|
|
3607
|
+
h('pre', { className: 'dsc-logs' }, String(preview.text ?? '')),
|
|
3608
|
+
Array.isArray(preview.unknown) && preview.unknown.length > 0
|
|
3609
|
+
? h('span', { className: 'dsc-warn' }, t('bili.unknownPlaceholder', { names: preview.unknown.join(', ') }))
|
|
3610
|
+
: null,
|
|
3611
|
+
h(
|
|
3612
|
+
'div',
|
|
3613
|
+
{ className: 'dsc-line' },
|
|
3614
|
+
preview.state === 'ready'
|
|
3615
|
+
? h(Btn, {
|
|
3616
|
+
kind: 'danger',
|
|
3617
|
+
disabled: busy !== '',
|
|
3618
|
+
onClick: () => {
|
|
3619
|
+
setPreview(null)
|
|
3620
|
+
void onAnnounce(data, preview, String(preview.text ?? ''), false)
|
|
3621
|
+
},
|
|
3622
|
+
}, t('bili.announce.confirm'))
|
|
3623
|
+
: h(Btn, {
|
|
3624
|
+
kind: 'danger',
|
|
3625
|
+
disabled: busy !== '',
|
|
3626
|
+
onClick: () => {
|
|
3627
|
+
setPreview(null)
|
|
3628
|
+
void onAnnounce(data, preview, String(preview.text ?? ''), true)
|
|
3629
|
+
},
|
|
3630
|
+
}, t('bili.announce.force')),
|
|
3631
|
+
h(Btn, { kind: 'quiet', onClick: () => setPreview(null) }, t('bili.announce.cancel')),
|
|
3632
|
+
),
|
|
3633
|
+
)
|
|
3634
|
+
: null,
|
|
3635
|
+
)
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
/**
|
|
3639
|
+
* The Settings page for Bilibili update notes.
|
|
3640
|
+
*
|
|
3641
|
+
* The panel answers "what would be posted, and to which video"; this page
|
|
3642
|
+
* answers "can this machine post at all, and what has it already said" — which
|
|
3643
|
+
* is configuration plus a public record, and belongs beside the two credential
|
|
3644
|
+
* pages that already live here.
|
|
3645
|
+
*/
|
|
3646
|
+
/* ------------------------------------------------------------- QR code -- */
|
|
3647
|
+
/*
|
|
3648
|
+
* A QR encoder, because the Bilibili sign-in hands over a string a PHONE has to
|
|
3649
|
+
* SCAN, and this half has nothing to draw one with: the client builtins are React,
|
|
3650
|
+
* `ctx`, `host.call`, `styles` and `console`, and this plugin has no dependencies by
|
|
3651
|
+
* design. The two ways out are both worse — an external QR image service would hand
|
|
3652
|
+
* a live sign-in URL to a third party, and adding a package would put a dependency
|
|
3653
|
+
* tree inside a plugin whose install story is "one tgz, no tree".
|
|
3654
|
+
*
|
|
3655
|
+
* Byte mode, error correction M, versions 1-10 (up to 213 bytes): enough for the
|
|
3656
|
+
* ~130-byte login URL with room to spare, and small enough to be checked rather than
|
|
3657
|
+
* trusted. Every table here was read out of a reference implementation instead of
|
|
3658
|
+
* recalled, and `tests/client-render.mjs` compares the modules this produces against
|
|
3659
|
+
* a matrix that reference produced, frozen into the test.
|
|
3660
|
+
*
|
|
3661
|
+
* The encoder is deliberately quiet about failure: a string that does not fit comes
|
|
3662
|
+
* back as `null` and the caller shows the link instead of a wrong code.
|
|
3663
|
+
*/
|
|
3664
|
+
|
|
3665
|
+
/** Total codewords (data + EC) per version, 1-10. */
|
|
3666
|
+
const QR_CODEWORDS = [26, 44, 70, 100, 134, 172, 196, 242, 292, 346]
|
|
3667
|
+
/** EC codewords for the whole symbol, level M. */
|
|
3668
|
+
const QR_EC_M = [10, 16, 26, 36, 48, 64, 72, 88, 110, 130]
|
|
3669
|
+
/** Reed-Solomon blocks, level M. */
|
|
3670
|
+
const QR_BLOCKS_M = [1, 1, 1, 2, 2, 4, 4, 4, 5, 5]
|
|
3671
|
+
|
|
3672
|
+
const QR_GF_EXP = new Uint8Array(512)
|
|
3673
|
+
const QR_GF_LOG = new Uint8Array(256)
|
|
3674
|
+
{
|
|
3675
|
+
let value = 1
|
|
3676
|
+
for (let i = 0; i < 255; i += 1) {
|
|
3677
|
+
QR_GF_EXP[i] = value
|
|
3678
|
+
QR_GF_LOG[value] = i
|
|
3679
|
+
value <<= 1
|
|
3680
|
+
if ((value & 0x100) !== 0) value ^= 0x11d
|
|
3681
|
+
}
|
|
3682
|
+
for (let i = 255; i < 512; i += 1) QR_GF_EXP[i] = QR_GF_EXP[i - 255]
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
const qrMultiply = (a, b) => (a === 0 || b === 0 ? 0 : QR_GF_EXP[QR_GF_LOG[a] + QR_GF_LOG[b]])
|
|
3686
|
+
|
|
3687
|
+
/** The generator polynomial of this degree, highest power first. */
|
|
3688
|
+
function qrGenerator(degree) {
|
|
3689
|
+
let poly = [1]
|
|
3690
|
+
for (let i = 0; i < degree; i += 1) {
|
|
3691
|
+
const next = new Array(poly.length + 1).fill(0)
|
|
3692
|
+
for (let j = 0; j < poly.length; j += 1) {
|
|
3693
|
+
next[j] ^= poly[j]
|
|
3694
|
+
next[j + 1] ^= qrMultiply(poly[j], QR_GF_EXP[i])
|
|
3695
|
+
}
|
|
3696
|
+
poly = next
|
|
3697
|
+
}
|
|
3698
|
+
return poly
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
/** The EC codewords for one data block. */
|
|
3702
|
+
function qrEcCodewords(block, ecCount) {
|
|
3703
|
+
const generator = qrGenerator(ecCount)
|
|
3704
|
+
const work = new Uint8Array(block.length + ecCount)
|
|
3705
|
+
work.set(block)
|
|
3706
|
+
for (let i = 0; i < block.length; i += 1) {
|
|
3707
|
+
const factor = work[i]
|
|
3708
|
+
if (factor === 0) continue
|
|
3709
|
+
for (let j = 0; j < generator.length; j += 1) work[i + j] ^= qrMultiply(generator[j], factor)
|
|
3710
|
+
}
|
|
3711
|
+
return work.slice(block.length)
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
/** The smallest version that holds this many bytes, or null. */
|
|
3715
|
+
function qrVersionFor(byteLength) {
|
|
3716
|
+
for (let version = 1; version <= QR_CODEWORDS.length; version += 1) {
|
|
3717
|
+
const countBits = version <= 9 ? 8 : 16
|
|
3718
|
+
const capacityBits = (QR_CODEWORDS[version - 1] - QR_EC_M[version - 1]) * 8
|
|
3719
|
+
if (4 + countBits + byteLength * 8 <= capacityBits) return version
|
|
3720
|
+
}
|
|
3721
|
+
return null
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
/** The final interleaved codeword stream for this text, or null if it does not fit. */
|
|
3725
|
+
function qrCodewords(bytes) {
|
|
3726
|
+
const version = qrVersionFor(bytes.length)
|
|
3727
|
+
if (version === null) return null
|
|
3728
|
+
const countBits = version <= 9 ? 8 : 16
|
|
3729
|
+
const dataCodewords = QR_CODEWORDS[version - 1] - QR_EC_M[version - 1]
|
|
3730
|
+
const dataBits = dataCodewords * 8
|
|
3731
|
+
const bits = []
|
|
3732
|
+
const push = (value, length) => {
|
|
3733
|
+
for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1)
|
|
3734
|
+
}
|
|
3735
|
+
push(0b0100, 4)
|
|
3736
|
+
push(bytes.length, countBits)
|
|
3737
|
+
for (const byte of bytes) push(byte, 8)
|
|
3738
|
+
push(0, Math.min(4, dataBits - bits.length))
|
|
3739
|
+
while (bits.length % 8 !== 0) bits.push(0)
|
|
3740
|
+
let padIndex = 0
|
|
3741
|
+
while (bits.length < dataBits) {
|
|
3742
|
+
push(padIndex % 2 === 0 ? 0xec : 0x11, 8)
|
|
3743
|
+
padIndex += 1
|
|
3744
|
+
}
|
|
3745
|
+
const data = new Uint8Array(dataCodewords)
|
|
3746
|
+
for (let i = 0; i < dataCodewords; i += 1) {
|
|
3747
|
+
let byte = 0
|
|
3748
|
+
for (let b = 0; b < 8; b += 1) byte = (byte << 1) | bits[i * 8 + b]
|
|
3749
|
+
data[i] = byte
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
const total = QR_CODEWORDS[version - 1]
|
|
3753
|
+
const ecTotal = QR_EC_M[version - 1]
|
|
3754
|
+
const blocks = QR_BLOCKS_M[version - 1]
|
|
3755
|
+
const group2 = total % blocks
|
|
3756
|
+
const group1 = blocks - group2
|
|
3757
|
+
const dataInGroup1 = Math.floor(dataCodewords / blocks)
|
|
3758
|
+
const ecCount = Math.floor(total / blocks) - dataInGroup1
|
|
3759
|
+
const dataBlocks = []
|
|
3760
|
+
const ecBlocks = []
|
|
3761
|
+
let offset = 0
|
|
3762
|
+
for (let b = 0; b < blocks; b += 1) {
|
|
3763
|
+
const size = b < group1 ? dataInGroup1 : dataInGroup1 + 1
|
|
3764
|
+
const block = data.slice(offset, offset + size)
|
|
3765
|
+
dataBlocks.push(block)
|
|
3766
|
+
ecBlocks.push(qrEcCodewords(block, ecCount))
|
|
3767
|
+
offset += size
|
|
3768
|
+
}
|
|
3769
|
+
const out = new Uint8Array(total)
|
|
3770
|
+
let index = 0
|
|
3771
|
+
const maxData = dataInGroup1 + (group2 > 0 ? 1 : 0)
|
|
3772
|
+
for (let i = 0; i < maxData; i += 1) {
|
|
3773
|
+
for (const block of dataBlocks) if (i < block.length) out[index++] = block[i]
|
|
3774
|
+
}
|
|
3775
|
+
for (let i = 0; i < ecCount; i += 1) {
|
|
3776
|
+
for (const block of ecBlocks) out[index++] = block[i]
|
|
3777
|
+
}
|
|
3778
|
+
return { version, codewords: out }
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
/** Centre coordinates of the alignment patterns, per the version. */
|
|
3782
|
+
function qrAlignmentPositions(version) {
|
|
3783
|
+
if (version === 1) return []
|
|
3784
|
+
const count = Math.floor(version / 7) + 2
|
|
3785
|
+
const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2
|
|
3786
|
+
const positions = [6]
|
|
3787
|
+
for (let pos = version * 4 + 10; positions.length < count; pos -= step) positions.splice(1, 0, pos)
|
|
3788
|
+
return positions
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
const qrMaskBit = (pattern, row, col) => {
|
|
3792
|
+
switch (pattern) {
|
|
3793
|
+
case 0: return (row + col) % 2 === 0
|
|
3794
|
+
case 1: return row % 2 === 0
|
|
3795
|
+
case 2: return col % 3 === 0
|
|
3796
|
+
case 3: return (row + col) % 3 === 0
|
|
3797
|
+
case 4: return (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0
|
|
3798
|
+
case 5: return ((row * col) % 2) + ((row * col) % 3) === 0
|
|
3799
|
+
case 6: return (((row * col) % 2) + ((row * col) % 3)) % 2 === 0
|
|
3800
|
+
default: return (((row + col) % 2) + ((row * col) % 3)) % 2 === 0
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
/** The 15 format bits for level M and this mask, BCH-protected and XORed. */
|
|
3805
|
+
function qrFormatBits(maskPattern) {
|
|
3806
|
+
const data = (0b00 << 3) | maskPattern
|
|
3807
|
+
let remainder = data << 10
|
|
3808
|
+
for (let i = 14; i >= 10; i -= 1) {
|
|
3809
|
+
if (((remainder >>> i) & 1) === 1) remainder ^= 0b10100110111 << (i - 10)
|
|
3810
|
+
}
|
|
3811
|
+
return ((data << 10) | remainder) ^ 0b101010000010010
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
/** The 18 version bits for version 7 and up. */
|
|
3815
|
+
function qrVersionBits(version) {
|
|
3816
|
+
let remainder = version << 12
|
|
3817
|
+
for (let i = 17; i >= 12; i -= 1) {
|
|
3818
|
+
if (((remainder >>> i) & 1) === 1) remainder ^= 0b1111100100101 << (i - 12)
|
|
3819
|
+
}
|
|
3820
|
+
return (version << 12) | remainder
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
/** One complete symbol, masked and with its format bits written. */
|
|
3824
|
+
function qrBuildMatrix(codewords, version, maskPattern) {
|
|
3825
|
+
const size = version * 4 + 17
|
|
3826
|
+
const modules = Array.from({ length: size }, () => new Uint8Array(size))
|
|
3827
|
+
const reserved = Array.from({ length: size }, () => new Uint8Array(size))
|
|
3828
|
+
const put = (row, col, dark) => {
|
|
3829
|
+
modules[row][col] = dark === true ? 1 : 0
|
|
3830
|
+
reserved[row][col] = 1
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
const finder = (top, left) => {
|
|
3834
|
+
for (let r = -1; r <= 7; r += 1) {
|
|
3835
|
+
for (let c = -1; c <= 7; c += 1) {
|
|
3836
|
+
const row = top + r
|
|
3837
|
+
const col = left + c
|
|
3838
|
+
if (row < 0 || row >= size || col < 0 || col >= size) continue
|
|
3839
|
+
const ring = (r === 0 || r === 6) && c >= 0 && c <= 6
|
|
3840
|
+
const side = (c === 0 || c === 6) && r >= 0 && r <= 6
|
|
3841
|
+
const core = r >= 2 && r <= 4 && c >= 2 && c <= 4
|
|
3842
|
+
put(row, col, ring || side || core)
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
finder(0, 0)
|
|
3847
|
+
finder(0, size - 7)
|
|
3848
|
+
finder(size - 7, 0)
|
|
3849
|
+
|
|
3850
|
+
for (let i = 8; i < size - 8; i += 1) {
|
|
3851
|
+
put(6, i, i % 2 === 0)
|
|
3852
|
+
put(i, 6, i % 2 === 0)
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
const alignment = qrAlignmentPositions(version)
|
|
3856
|
+
for (const row of alignment) {
|
|
3857
|
+
for (const col of alignment) {
|
|
3858
|
+
const overlapsFinder = (row === 6 && col === 6) || (row === 6 && col === size - 7) || (row === size - 7 && col === 6)
|
|
3859
|
+
if (overlapsFinder) continue
|
|
3860
|
+
for (let r = -2; r <= 2; r += 1) {
|
|
3861
|
+
for (let c = -2; c <= 2; c += 1) put(row + r, col + c, Math.max(Math.abs(r), Math.abs(c)) !== 1)
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
/* Reserve the format and version areas before the data goes in, so the data
|
|
3867
|
+
placement skips them and the mask never touches them. */
|
|
3868
|
+
for (let i = 0; i <= 8; i += 1) {
|
|
3869
|
+
if (i !== 6) {
|
|
3870
|
+
reserved[8][i] = 1
|
|
3871
|
+
reserved[i][8] = 1
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
reserved[8][8] = 1
|
|
3875
|
+
for (let i = 0; i < 8; i += 1) {
|
|
3876
|
+
reserved[8][size - 1 - i] = 1
|
|
3877
|
+
reserved[size - 1 - i][8] = 1
|
|
3878
|
+
}
|
|
3879
|
+
put(size - 8, 8, true)
|
|
3880
|
+
if (version >= 7) {
|
|
3881
|
+
for (let i = 0; i < 18; i += 1) {
|
|
3882
|
+
const row = Math.floor(i / 3)
|
|
3883
|
+
const col = size - 11 + (i % 3)
|
|
3884
|
+
reserved[row][col] = 1
|
|
3885
|
+
reserved[col][row] = 1
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
const totalBits = codewords.length * 8
|
|
3890
|
+
let bitIndex = 0
|
|
3891
|
+
let upward = true
|
|
3892
|
+
for (let col = size - 1; col > 0; col -= 2) {
|
|
3893
|
+
if (col === 6) col -= 1
|
|
3894
|
+
for (let i = 0; i < size; i += 1) {
|
|
3895
|
+
const row = upward ? size - 1 - i : i
|
|
3896
|
+
for (const c of [col, col - 1]) {
|
|
3897
|
+
if (reserved[row][c] === 1) continue
|
|
3898
|
+
const bit = bitIndex < totalBits ? (codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1 : 0
|
|
3899
|
+
modules[row][c] = bit
|
|
3900
|
+
bitIndex += 1
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
upward = !upward
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
for (let row = 0; row < size; row += 1) {
|
|
3907
|
+
for (let col = 0; col < size; col += 1) {
|
|
3908
|
+
if (reserved[row][col] === 0 && qrMaskBit(maskPattern, row, col)) modules[row][col] ^= 1
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
/*
|
|
3913
|
+
* The two format strips, and which bit goes where. They are not two copies laid
|
|
3914
|
+
* out the same way: the strip beside the TOP-LEFT finder is the VERTICAL one
|
|
3915
|
+
* (column 8, bits 0-7 climbing the corner then bits 8-14 up the bottom-left), and
|
|
3916
|
+
* the other is the HORIZONTAL one (row 8, bits 0-7 running left from the
|
|
3917
|
+
* bottom-right corner, then bit 8 and bits 9-14 back toward the corner). Getting
|
|
3918
|
+
* this backwards produces a perfectly ordinary-looking symbol that no scanner
|
|
3919
|
+
* reads — which is why it is checked against a reference matrix, not by eye.
|
|
3920
|
+
*/
|
|
3921
|
+
const format = qrFormatBits(maskPattern)
|
|
3922
|
+
for (let i = 0; i < 15; i += 1) {
|
|
3923
|
+
const bit = ((format >>> i) & 1) === 1
|
|
3924
|
+
if (i < 6) modules[i][8] = bit ? 1 : 0
|
|
3925
|
+
else if (i < 8) modules[i + 1][8] = bit ? 1 : 0
|
|
3926
|
+
else modules[size - 15 + i][8] = bit ? 1 : 0
|
|
3927
|
+
if (i < 8) modules[8][size - i - 1] = bit ? 1 : 0
|
|
3928
|
+
else if (i < 9) modules[8][7] = bit ? 1 : 0
|
|
3929
|
+
else modules[8][14 - i] = bit ? 1 : 0
|
|
3930
|
+
}
|
|
3931
|
+
if (version >= 7) {
|
|
3932
|
+
const info = qrVersionBits(version)
|
|
3933
|
+
for (let i = 0; i < 18; i += 1) {
|
|
3934
|
+
const bit = ((info >>> i) & 1) === 1 ? 1 : 0
|
|
3935
|
+
const row = Math.floor(i / 3)
|
|
3936
|
+
const col = size - 11 + (i % 3)
|
|
3937
|
+
modules[row][col] = bit
|
|
3938
|
+
modules[col][row] = bit
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
return { modules, size }
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
/** The spec's four penalty rules, used to pick the least confusing mask. */
|
|
3945
|
+
function qrPenalty(modules, size) {
|
|
3946
|
+
let penalty = 0
|
|
3947
|
+
const at = (row, col) => modules[row][col]
|
|
3948
|
+
for (let i = 0; i < size; i += 1) {
|
|
3949
|
+
for (const vertical of [false, true]) {
|
|
3950
|
+
let run = 1
|
|
3951
|
+
for (let j = 1; j < size; j += 1) {
|
|
3952
|
+
const previous = vertical ? at(j - 1, i) : at(i, j - 1)
|
|
3953
|
+
const current = vertical ? at(j, i) : at(i, j)
|
|
3954
|
+
if (current === previous) {
|
|
3955
|
+
run += 1
|
|
3956
|
+
} else {
|
|
3957
|
+
if (run >= 5) penalty += 3 + (run - 5)
|
|
3958
|
+
run = 1
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
if (run >= 5) penalty += 3 + (run - 5)
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
for (let row = 0; row < size - 1; row += 1) {
|
|
3965
|
+
for (let col = 0; col < size - 1; col += 1) {
|
|
3966
|
+
const first = at(row, col)
|
|
3967
|
+
if (first === at(row, col + 1) && first === at(row + 1, col) && first === at(row + 1, col + 1)) penalty += 3
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
const patternA = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]
|
|
3971
|
+
const patternB = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]
|
|
3972
|
+
for (let i = 0; i < size; i += 1) {
|
|
3973
|
+
for (let j = 0; j + 11 <= size; j += 1) {
|
|
3974
|
+
for (const vertical of [false, true]) {
|
|
3975
|
+
let matchesA = true
|
|
3976
|
+
let matchesB = true
|
|
3977
|
+
for (let k = 0; k < 11; k += 1) {
|
|
3978
|
+
const value = vertical ? at(j + k, i) : at(i, j + k)
|
|
3979
|
+
if (value !== patternA[k]) matchesA = false
|
|
3980
|
+
if (value !== patternB[k]) matchesB = false
|
|
3981
|
+
}
|
|
3982
|
+
if (matchesA) penalty += 40
|
|
3983
|
+
if (matchesB) penalty += 40
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
let dark = 0
|
|
3988
|
+
for (let row = 0; row < size; row += 1) {
|
|
3989
|
+
for (let col = 0; col < size; col += 1) dark += at(row, col) === 1 ? 1 : 0
|
|
3990
|
+
}
|
|
3991
|
+
const percent = (dark * 100) / (size * size)
|
|
3992
|
+
penalty += Math.floor(Math.abs(percent - 50) / 5) * 10
|
|
3993
|
+
return penalty
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
/** UTF-8 bytes without a dependency on `TextEncoder`, which a sandbox may not have. */
|
|
3997
|
+
function qrUtf8(text) {
|
|
3998
|
+
const bytes = []
|
|
3999
|
+
for (const character of String(text)) {
|
|
4000
|
+
const code = character.codePointAt(0)
|
|
4001
|
+
if (code < 0x80) bytes.push(code)
|
|
4002
|
+
else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
|
|
4003
|
+
else if (code < 0x10000) bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
|
|
4004
|
+
else bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
|
|
4005
|
+
}
|
|
4006
|
+
return bytes
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
/**
|
|
4010
|
+
* A QR code for this text, as horizontal runs of dark modules.
|
|
4011
|
+
*
|
|
4012
|
+
* Runs rather than one rect per module: a version-8 symbol has ~1000 dark modules
|
|
4013
|
+
* and ~250 runs, and the panel redraws on every poll.
|
|
4014
|
+
*
|
|
4015
|
+
* @param {string} text - what the phone should read.
|
|
4016
|
+
* @returns {{size: number, runs: Array<{row: number, col: number, width: number}>}|null}
|
|
4017
|
+
*/
|
|
4018
|
+
function qrFor(text) {
|
|
4019
|
+
const encoded = qrCodewords(qrUtf8(text))
|
|
4020
|
+
if (encoded === null) return null
|
|
4021
|
+
let best = null
|
|
4022
|
+
for (let mask = 0; mask < 8; mask += 1) {
|
|
4023
|
+
const candidate = qrBuildMatrix(encoded.codewords, encoded.version, mask)
|
|
4024
|
+
const score = qrPenalty(candidate.modules, candidate.size)
|
|
4025
|
+
if (best === null || score < best.score) best = { score, ...candidate }
|
|
4026
|
+
}
|
|
4027
|
+
const runs = []
|
|
4028
|
+
for (let row = 0; row < best.size; row += 1) {
|
|
4029
|
+
let col = 0
|
|
4030
|
+
while (col < best.size) {
|
|
4031
|
+
if (best.modules[row][col] === 0) {
|
|
4032
|
+
col += 1
|
|
4033
|
+
continue
|
|
4034
|
+
}
|
|
4035
|
+
let width = 1
|
|
4036
|
+
while (col + width < best.size && best.modules[row][col + width] === 1) width += 1
|
|
4037
|
+
runs.push({ row, col, width })
|
|
4038
|
+
col += width
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
return { size: best.size, runs }
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
function QrCode(props) {
|
|
4045
|
+
const code = props.code
|
|
4046
|
+
return h(
|
|
4047
|
+
'svg',
|
|
4048
|
+
{
|
|
4049
|
+
className: 'dsc-qr',
|
|
4050
|
+
viewBox: `0 0 ${code.size} ${code.size}`,
|
|
4051
|
+
role: 'img',
|
|
4052
|
+
'aria-label': props.label,
|
|
4053
|
+
shapeRendering: 'crispEdges',
|
|
4054
|
+
},
|
|
4055
|
+
...code.runs.map((run, index) => h('rect', { key: `r${index}`, x: run.col, y: run.row, width: run.width, height: 1 })),
|
|
4056
|
+
)
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
function BilibiliPage(props) {
|
|
4060
|
+
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
4061
|
+
const { bili, error, setError, loadBili } = useConsoleState()
|
|
4062
|
+
const [busy, setBusy] = React.useState('')
|
|
4063
|
+
const [notice, setNotice] = React.useState(null)
|
|
4064
|
+
|
|
4065
|
+
React.useEffect(() => {
|
|
4066
|
+
void loadBili({ force: true })
|
|
4067
|
+
}, [loadBili])
|
|
4068
|
+
|
|
4069
|
+
const reload = React.useCallback(() => loadBili({ force: true }), [loadBili])
|
|
4070
|
+
const repositories = Array.isArray(bili?.repos) ? bili.repos : []
|
|
4071
|
+
const lastSweep = bili?.lastSweep ?? null
|
|
4072
|
+
const sweepText = (() => {
|
|
4073
|
+
if (bili === null) return null
|
|
4074
|
+
if (lastSweep === null || lastSweep === undefined) return t('bili.neverSwept')
|
|
4075
|
+
const results = Array.isArray(lastSweep.results) ? lastSweep.results : []
|
|
4076
|
+
const announced = results.filter((entry) => entry.state === 'announced')
|
|
4077
|
+
const failed = results.filter((entry) => entry.state === 'failed')
|
|
4078
|
+
const summary = announced.length > 0
|
|
4079
|
+
? announced.map((entry) => t('bili.sweep.announced', { repo: String(entry.repo ?? ''), tag: String(entry.tag ?? '') })).join(',')
|
|
4080
|
+
: failed.length > 0
|
|
4081
|
+
? failed.map((entry) => t('bili.sweep.failed', { repo: String(entry.repo ?? ''), tag: String(entry.tag ?? '') })).join(',')
|
|
4082
|
+
: t('bili.sweep.none')
|
|
4083
|
+
return t('bili.lastSweep', { time: stamp(String(lastSweep.at ?? '')), summary })
|
|
4084
|
+
})()
|
|
4085
|
+
|
|
4086
|
+
return h(
|
|
4087
|
+
'div',
|
|
4088
|
+
{ className: 'dsc-root' },
|
|
4089
|
+
h(
|
|
4090
|
+
'div',
|
|
4091
|
+
{ className: 'dsc-bar' },
|
|
4092
|
+
h(
|
|
4093
|
+
'div',
|
|
4094
|
+
{ className: 'dsc-heading' },
|
|
4095
|
+
h('span', { className: 'dsc-title' }, t('bili.settings.title')),
|
|
4096
|
+
h('span', { className: 'dsc-subtitle' }, t('bili.settings.subtitle')),
|
|
4097
|
+
),
|
|
4098
|
+
h(Btn, { disabled: busy !== '', onClick: () => { void reload() } }, t('action.recheck')),
|
|
4099
|
+
),
|
|
4100
|
+
|
|
4101
|
+
error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
|
|
4102
|
+
notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
|
|
4103
|
+
|
|
4104
|
+
bili === null
|
|
4105
|
+
? h('div', { className: 'dsc-empty' }, t('state.loading'))
|
|
4106
|
+
: h(
|
|
4107
|
+
React.Fragment,
|
|
4108
|
+
null,
|
|
4109
|
+
bili.enabled === false
|
|
4110
|
+
? h('div', { className: 'dsc-warn' }, 'bilibiliEnabled: false')
|
|
4111
|
+
: null,
|
|
4112
|
+
bili.watchSeconds === 0 ? h('div', { className: 'dsc-warn' }, t('bili.watch.off')) : null,
|
|
4113
|
+
bili.ledger?.problem
|
|
4114
|
+
? h('div', { className: 'dsc-error', role: 'alert' }, t('bili.ledger.problem', { problem: String(bili.ledger.problem) }))
|
|
4115
|
+
: null,
|
|
4116
|
+
h(BilibiliSignIn, {
|
|
4117
|
+
t,
|
|
4118
|
+
bili,
|
|
4119
|
+
busy,
|
|
4120
|
+
setBusy,
|
|
4121
|
+
setNotice,
|
|
4122
|
+
setError,
|
|
4123
|
+
onChanged: () => loadBili({ force: true }),
|
|
4124
|
+
}),
|
|
4125
|
+
h(
|
|
4126
|
+
'div',
|
|
4127
|
+
{ className: 'dsc-setup' },
|
|
4128
|
+
h('span', { className: 'dsc-setup-strong' }, t('bili.template')),
|
|
4129
|
+
h('span', { className: 'dsc-mono' }, String(bili.template === '' ? '【更新 {tag}】{summary}' : bili.template)),
|
|
4130
|
+
h('span', { className: 'dsc-quiet' }, t('bili.template.hint')),
|
|
4131
|
+
sweepText === null ? null : h('span', { className: 'dsc-quiet' }, sweepText),
|
|
4132
|
+
),
|
|
4133
|
+
h(
|
|
4134
|
+
'div',
|
|
4135
|
+
{ className: 'dsc-sub' },
|
|
4136
|
+
h('span', { className: 'dsc-setup-strong' }, t('bili.repos.title')),
|
|
4137
|
+
repositories.length === 0
|
|
4138
|
+
? h('span', null, t('bili.repos.empty'))
|
|
4139
|
+
: repositories.map((entry) =>
|
|
4140
|
+
h(
|
|
4141
|
+
'div',
|
|
4142
|
+
{ className: 'dsc-line', key: entry.repo },
|
|
4143
|
+
h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(entry.label ?? entry.repo)),
|
|
4144
|
+
entry.binding === null
|
|
4145
|
+
? h(Chip, { state: 'idle' }, t('bili.repo.none'))
|
|
4146
|
+
: h(Chip, { state: 'success' }, String(entry.binding.bvid)),
|
|
4147
|
+
h('span', { className: 'dsc-grow' }, entry.video?.ok === true
|
|
4148
|
+
? String(entry.video.title ?? '')
|
|
4149
|
+
: (Array.isArray(entry.announced) && entry.announced.length > 0
|
|
4150
|
+
? t('bili.chip.announced', { tag: String(entry.announced[entry.announced.length - 1].tag) })
|
|
4151
|
+
: t('bili.announced.none'))),
|
|
4152
|
+
Array.isArray(entry.failures) && entry.failures.length > 0
|
|
4153
|
+
? h('span', { className: 'dsc-warn' }, t('bili.failure.last', { message: String(entry.failures[entry.failures.length - 1].message ?? '') }))
|
|
4154
|
+
: null,
|
|
4155
|
+
entry.commentUrl
|
|
4156
|
+
? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(entry.commentUrl), '_blank', 'noopener,noreferrer') }, t('action.open'))
|
|
4157
|
+
: null,
|
|
4158
|
+
),
|
|
4159
|
+
),
|
|
4160
|
+
),
|
|
4161
|
+
),
|
|
4162
|
+
)
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
/** The Settings page: account, scopes, and the repository list. */
|
|
4166
|
+
function AccountPage(props) {
|
|
4167
|
+
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
4168
|
+
const { status, error, setError, loadStatus } = useConsoleState()
|
|
4169
|
+
const [managing, setManaging] = React.useState(false)
|
|
4170
|
+
const [confirmingSignOut, setConfirmingSignOut] = React.useState(false)
|
|
4171
|
+
const [signOutBusy, setSignOutBusy] = React.useState(false)
|
|
4172
|
+
const [notice, setNotice] = React.useState(null)
|
|
4173
|
+
|
|
4174
|
+
React.useEffect(() => {
|
|
4175
|
+
void loadStatus()
|
|
4176
|
+
}, [loadStatus])
|
|
4177
|
+
|
|
4178
|
+
const gh = status?.gh ?? {}
|
|
4179
|
+
const config = status?.config ?? {}
|
|
4180
|
+
const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
|
|
4181
|
+
const scopes = Array.isArray(gh.scopes) ? gh.scopes : []
|
|
4182
|
+
const registered = Array.isArray(status?.repos) ? status.repos.length : 0
|
|
4183
|
+
const stale = status !== null && status.protocol !== PROTOCOL
|
|
4184
|
+
|
|
4185
|
+
return h(
|
|
4186
|
+
'div',
|
|
4187
|
+
{ className: 'dsc-root' },
|
|
4188
|
+
h(
|
|
4189
|
+
'div',
|
|
4190
|
+
{ className: 'dsc-bar' },
|
|
4191
|
+
h(
|
|
4192
|
+
'div',
|
|
4193
|
+
{ className: 'dsc-heading' },
|
|
4194
|
+
h('span', { className: 'dsc-title' }, t('settings.title')),
|
|
4195
|
+
h('span', { className: 'dsc-subtitle' }, t('settings.subtitle')),
|
|
4196
|
+
),
|
|
4197
|
+
h(Btn, { onClick: () => void loadStatus() }, t('action.recheck')),
|
|
4198
|
+
/* Signing out is destructive and easy to hit by accident, so it takes two
|
|
4199
|
+
clicks — the same shape as publishing a draft. */
|
|
4200
|
+
gh.authenticated === true
|
|
4201
|
+
? h(Btn, {
|
|
4202
|
+
kind: 'quiet',
|
|
4203
|
+
disabled: signOutBusy,
|
|
4204
|
+
title: t('state.signedOut'),
|
|
4205
|
+
onClick: () => {
|
|
4206
|
+
if (!confirmingSignOut) {
|
|
4207
|
+
setConfirmingSignOut(true)
|
|
4208
|
+
return
|
|
4209
|
+
}
|
|
4210
|
+
void (async () => {
|
|
4211
|
+
setSignOutBusy(true)
|
|
4212
|
+
const result = await postJson('/auth-logout', {}, ACTION_TIMEOUT_MS)
|
|
4213
|
+
setSignOutBusy(false)
|
|
4214
|
+
setConfirmingSignOut(false)
|
|
4215
|
+
if (!result.ok || result.payload?.ok !== true) {
|
|
4216
|
+
setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
|
|
4217
|
+
return
|
|
4218
|
+
}
|
|
4219
|
+
setNotice(t('state.signedOut'))
|
|
4220
|
+
void loadStatus()
|
|
4221
|
+
})()
|
|
4222
|
+
},
|
|
4223
|
+
}, signOutBusy ? '…' : confirmingSignOut ? t('action.signOutConfirm') : t('action.signOut'))
|
|
4224
|
+
: null,
|
|
4225
|
+
),
|
|
4226
|
+
|
|
4227
|
+
error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
|
|
4228
|
+
notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
|
|
4229
|
+
stale
|
|
4230
|
+
? h('div', { className: 'dsc-warn', role: 'alert' }, h('strong', null, t('stale.title')), ' — ', t('stale.how'))
|
|
4231
|
+
: null,
|
|
4232
|
+
|
|
4233
|
+
status === null
|
|
4234
|
+
? h('div', { className: 'dsc-empty' }, t('state.loading'))
|
|
4235
|
+
: h(
|
|
4236
|
+
React.Fragment,
|
|
4237
|
+
null,
|
|
4238
|
+
h(
|
|
4239
|
+
'div',
|
|
4240
|
+
{ className: 'dsc-list' },
|
|
4241
|
+
h(
|
|
4242
|
+
'div',
|
|
4243
|
+
{ className: 'dsc-item' },
|
|
4244
|
+
h(
|
|
4245
|
+
'div',
|
|
4246
|
+
{ className: 'dsc-row' },
|
|
4247
|
+
h('span', { className: 'dsc-dot', 'data-state': gh.available !== true ? 'error' : gh.authenticated !== true ? 'warn' : 'success' }),
|
|
4248
|
+
h('span', { className: 'dsc-name' }, t('meta.gh')),
|
|
4249
|
+
h('span', { className: 'dsc-grow' }, gh.available !== true ? (gh.message ?? 'not found') : (gh.version ?? 'present')),
|
|
4250
|
+
gh.authenticated === true && typeof gh.account === 'string' && gh.account !== ''
|
|
4251
|
+
? h(Chip, { state: 'success' }, gh.account)
|
|
4252
|
+
: h(Chip, { state: 'warn' }, t('setup.needSignIn')),
|
|
4253
|
+
),
|
|
4254
|
+
),
|
|
4255
|
+
h(
|
|
4256
|
+
'div',
|
|
4257
|
+
{ className: 'dsc-item' },
|
|
4258
|
+
h(
|
|
4259
|
+
'div',
|
|
4260
|
+
{ className: 'dsc-row' },
|
|
4261
|
+
h('span', { className: 'dsc-dot', 'data-state': missing.length > 0 ? 'warn' : 'success' }),
|
|
4262
|
+
h('span', { className: 'dsc-name' }, t('meta.scopes')),
|
|
4263
|
+
h('span', { className: 'dsc-grow' }, gh.authenticated !== true ? '—' : scopes.length > 0 ? scopes.join(', ') : t('meta.noScopes')),
|
|
4264
|
+
missing.length > 0 ? h(Chip, { state: 'warn' }, missing.join(', ')) : null,
|
|
4265
|
+
),
|
|
4266
|
+
),
|
|
4267
|
+
h(
|
|
4268
|
+
'div',
|
|
4269
|
+
{ className: 'dsc-item' },
|
|
4270
|
+
h(
|
|
4271
|
+
'div',
|
|
4272
|
+
{ className: 'dsc-row' },
|
|
4273
|
+
h('span', { className: 'dsc-dot', 'data-state': 'idle' }),
|
|
4274
|
+
h('span', { className: 'dsc-name' }, t('meta.configFrom')),
|
|
4275
|
+
h('span', { className: 'dsc-grow dsc-mono' }, `${status.configSource ?? '—'} · ${status.configFile ?? ''}`),
|
|
4276
|
+
h('span', null, String(registered)),
|
|
4277
|
+
),
|
|
4278
|
+
),
|
|
4279
|
+
),
|
|
4280
|
+
|
|
4281
|
+
needsAccountSetup(gh) && !stale
|
|
4282
|
+
? h(
|
|
4283
|
+
'div',
|
|
4284
|
+
{ className: 'dsc-setup' },
|
|
4285
|
+
h('span', { className: 'dsc-setup-strong' }, gh.available !== true
|
|
4286
|
+
? t('setup.needGh')
|
|
4287
|
+
: gh.authenticated !== true
|
|
4288
|
+
? t('setup.needSignIn')
|
|
4289
|
+
: t('setup.needScopes', { scopes: missing.join(', ') })),
|
|
4290
|
+
missing.length > 0 ? h('span', null, t('setup.scopeHint')) : null,
|
|
4291
|
+
h(SignIn, { t, status, onChanged: () => void loadStatus() }),
|
|
4292
|
+
)
|
|
4293
|
+
: null,
|
|
4294
|
+
|
|
4295
|
+
h(
|
|
4296
|
+
'div',
|
|
4297
|
+
{ className: 'dsc-bar' },
|
|
4298
|
+
h('span', { className: 'dsc-subtitle' }, `${t('meta.projectsRoot')}: ${typeof config.projectsRoot === 'string' && config.projectsRoot !== '' ? config.projectsRoot : t('meta.projectsRootUnset')}`),
|
|
4299
|
+
h(Btn, { kind: managing ? 'primary' : undefined, onClick: () => setManaging((value) => !value) }, managing ? t('action.close') : t('action.manage')),
|
|
4300
|
+
),
|
|
4301
|
+
managing && !stale ? h(RepoPicker, { t, onChanged: () => void loadStatus() }) : null,
|
|
4302
|
+
),
|
|
4303
|
+
)
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
/**
|
|
4307
|
+
* The console's settings page: three credentials, one page.
|
|
4308
|
+
*
|
|
4309
|
+
* One segment mounts at a time, and that is deliberate twice over. It keeps the
|
|
4310
|
+
* page from probing all three credential systems the moment someone opens it —
|
|
4311
|
+
* each segment asks its own Host route when it mounts, as it did as a page of its
|
|
4312
|
+
* own. And it keeps a failure local: the Bilibili half parses a ledger and a cookie
|
|
4313
|
+
* file, and a malformed one takes down the segment it is in, not the GitHub token
|
|
4314
|
+
* state someone opened this page to read.
|
|
4315
|
+
*
|
|
4316
|
+
* The segments render here rather than in the slot because `settings.plugins.tab`
|
|
4317
|
+
* is a flat list — the platform gives one strip of tabs and no nesting — so the
|
|
4318
|
+
* inner strip is ours. `order` in that outer strip is one row per plugin; inside,
|
|
4319
|
+
* the order is the workflow: release, distribute on npm, then announce.
|
|
4320
|
+
*/
|
|
4321
|
+
function ConsoleSettingsPage(props) {
|
|
4322
|
+
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
4323
|
+
const [segment, setSegment] = React.useState(SEGMENT_GITHUB)
|
|
4324
|
+
const segments = [
|
|
4325
|
+
{ key: SEGMENT_GITHUB, label: 'settings.segment.github', page: AccountPage },
|
|
4326
|
+
{ key: SEGMENT_NPM, label: 'settings.segment.npm', page: NpmCredentialsPage },
|
|
4327
|
+
{ key: SEGMENT_BILIBILI, label: 'settings.segment.bilibili', page: BilibiliPage },
|
|
4328
|
+
]
|
|
4329
|
+
const active = segments.find((entry) => entry.key === segment) ?? segments[0]
|
|
4330
|
+
return h(
|
|
4331
|
+
'div',
|
|
4332
|
+
{ className: 'dsc-settings', 'data-segment': active.key },
|
|
4333
|
+
h(
|
|
4334
|
+
'div',
|
|
4335
|
+
{ className: 'dsc-bar dsc-segments' },
|
|
4336
|
+
...segments.map((entry) => h(
|
|
4337
|
+
Btn,
|
|
4338
|
+
{
|
|
4339
|
+
key: entry.key,
|
|
4340
|
+
kind: entry.key === active.key ? 'primary' : 'quiet',
|
|
4341
|
+
'data-segment': entry.key,
|
|
4342
|
+
title: t(entry.label),
|
|
4343
|
+
onClick: () => setSegment(entry.key),
|
|
4344
|
+
},
|
|
4345
|
+
t(entry.label),
|
|
4346
|
+
)),
|
|
4347
|
+
),
|
|
4348
|
+
h(active.page, { t }),
|
|
4349
|
+
)
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
return {
|
|
4353
|
+
name: 'dsh-cicd-client',
|
|
4354
|
+
inject: ['slots', 'locale'],
|
|
4355
|
+
apply(ctx) {
|
|
4356
|
+
ensureStyles()
|
|
4357
|
+
ctx.effect(
|
|
4358
|
+
() => ctx.locale.register(NS, { zh, en }),
|
|
4359
|
+
'dsh-plugin-cicd: dictionaries',
|
|
4360
|
+
)
|
|
4361
|
+
const t = ctx.locale.bind(NS)
|
|
4362
|
+
const disposers = [
|
|
4363
|
+
ctx.slots.inject('main', () =>
|
|
4364
|
+
ctx.slots.register(
|
|
4365
|
+
{ name: 'main', key: PANEL_ID, locale: NS },
|
|
4366
|
+
ConsolePage,
|
|
4367
|
+
),
|
|
4368
|
+
),
|
|
4369
|
+
// `order: 16` sits the entry after the knowledge console (15) and before
|
|
4370
|
+
// whatever a later plugin adds; the sidebar owns the button itself.
|
|
4371
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
4372
|
+
ctx.slots.register(
|
|
4373
|
+
{ name: 'sidebar.panellist', id: PANEL_ID, order: 16, label: () => t('panel'), locale: NS },
|
|
4374
|
+
ConsoleIcon,
|
|
4375
|
+
),
|
|
4376
|
+
),
|
|
4377
|
+
/*
|
|
4378
|
+
* ONE page, in the Plugins section, holding the three credentials.
|
|
4379
|
+
*
|
|
4380
|
+
* Two shares of a shared surface were given back here. The top-level nav
|
|
4381
|
+
* (`settings.section`) is a FLAT list — one page per entry, no grouping, no
|
|
4382
|
+
* sub-navigation — and this plugin was taking 3 of its 13 rows, all of them
|
|
4383
|
+
* above 通用 / 模型 / 插件, while every other third-party plugin on this machine
|
|
4384
|
+
* takes exactly one. `settings.plugins.tab` is the seat the platform keeps for
|
|
4385
|
+
* a plugin's own pages ("One page inside the Plugins settings section"), so the
|
|
4386
|
+
* configuration belongs there — but three tabs in that strip would be the same
|
|
4387
|
+
* unfair share one level down: a strip shared with every plugin, three of its
|
|
4388
|
+
* rows belonging to this one. So the three are segments inside one page, and
|
|
4389
|
+
* the strip is one row per plugin.
|
|
4390
|
+
*
|
|
4391
|
+
* `order` 20 puts it after the inventory (`all`, 10) and before the marketplace
|
|
4392
|
+
* (60): it configures an installed plugin, which is closer to the inventory than
|
|
4393
|
+
* to a shop.
|
|
4394
|
+
*
|
|
4395
|
+
* A fresh id is required — reusing a shipped id would REPLACE that page instead
|
|
4396
|
+
* of adding one. The `locale` option is what supplies the `t` prop
|
|
4397
|
+
* (`PropsLocale`), and it is the registration's own option rather than anything
|
|
4398
|
+
* the slot owner gives, so the page translates exactly as the sections did.
|
|
4399
|
+
*/
|
|
4400
|
+
ctx.slots.inject('settings.plugins.tab', () =>
|
|
4401
|
+
ctx.slots.register(
|
|
4402
|
+
{ name: 'settings.plugins.tab', id: SETTINGS_ID, order: 20, label: () => t('settings.consoleLabel'), locale: NS },
|
|
4403
|
+
ConsoleSettingsPage,
|
|
4404
|
+
),
|
|
4405
|
+
),
|
|
4406
|
+
]
|
|
4407
|
+
if (typeof ctx.effect === 'function') {
|
|
4408
|
+
ctx.effect(() => () => {
|
|
4409
|
+
for (const dispose of disposers) {
|
|
4410
|
+
if (typeof dispose === 'function') dispose()
|
|
4411
|
+
}
|
|
4412
|
+
}, 'dsh-plugin-cicd: panel registrations')
|
|
4413
|
+
}
|
|
4414
|
+
},
|
|
4415
|
+
}
|
|
4416
|
+
},
|
|
4417
|
+
})
|