dsh-plugin-cicd 0.5.0 → 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/README.md +16 -3
- package/client.js +703 -72
- package/index.js +280 -4
- package/lib/bilibili.mjs +182 -4
- package/package.json +1 -1
- package/tests/bilibili-check.mjs +52 -0
- package/tests/bump-e2e.mjs +79 -0
- package/tests/client-render.mjs +217 -1
- package/tests/host-checks.mjs +56 -1
- package/tests/mount-check.mjs +3 -0
package/client.js
CHANGED
|
@@ -200,6 +200,14 @@ window.__ModuleLoader__.load({
|
|
|
200
200
|
'meta.version': '本地',
|
|
201
201
|
'meta.expectedTag': '期望 tag {tag}',
|
|
202
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}。',
|
|
203
211
|
'meta.assets': '{count} 个资产',
|
|
204
212
|
'meta.path': '路径',
|
|
205
213
|
'meta.scopes': '权限',
|
|
@@ -377,8 +385,10 @@ window.__ModuleLoader__.load({
|
|
|
377
385
|
'bili.credential.source': '凭据文件 {path}',
|
|
378
386
|
'bili.credential.external': '这份凭据来自配置指定的外部文件(不是面板写入的):{path}',
|
|
379
387
|
'bili.signIn': '登录 B 站',
|
|
380
|
-
'bili.signIn.hint': '
|
|
381
|
-
'bili.
|
|
388
|
+
'bili.signIn.hint': '点一下会生成一个二维码:用手机 B 站 App 扫,然后在手机上确认。这里会自动继续,不需要终端。扫不动就用下面粘贴的方式。',
|
|
389
|
+
'bili.qrLabel': 'B 站登录二维码',
|
|
390
|
+
'bili.qrHint': '用手机 B 站 App 扫码。二维码约 3 分钟后过期,过期就再点一次【登录 B 站】。',
|
|
391
|
+
'bili.qrTooLong': '这条登录链接太长,画不成二维码——请改用下面粘贴 SESSDATA 的方式。',
|
|
382
392
|
'bili.waiting': '等待扫码…',
|
|
383
393
|
'bili.waitingScanned': '已扫码,请在手机上确认…',
|
|
384
394
|
'bili.signIn.done': '已登录:{uname}',
|
|
@@ -401,6 +411,7 @@ window.__ModuleLoader__.load({
|
|
|
401
411
|
'bili.bind.placeholder': 'BV 号,如 BV1RopP6FEJp',
|
|
402
412
|
'bili.bind.save': '绑定',
|
|
403
413
|
'bili.bind.unbind': '解绑',
|
|
414
|
+
'bili.bind.locked': '已绑定:要换视频先【解绑】。',
|
|
404
415
|
'bili.bind.done': '已绑定 {bvid}{baseline}',
|
|
405
416
|
'bili.bind.baseline': '(基线 {tag}:只有之后发布的新版本会自动播报)',
|
|
406
417
|
'bili.bind.baselineUnknown': '(读不到 Release 列表,绑定时刻之前的版本都按已公开处理)',
|
|
@@ -414,7 +425,10 @@ window.__ModuleLoader__.load({
|
|
|
414
425
|
'bili.announce.confirm': '确认发送',
|
|
415
426
|
'bili.announce.force': '仍然发送',
|
|
416
427
|
'bili.announce.cancel': '取消',
|
|
417
|
-
'bili.announce.sent': '已发送到 {bvid} 的评论区({tag}
|
|
428
|
+
'bili.announce.sent': '已发送到 {bvid} 的评论区({tag}),并且已经能公开读到。',
|
|
429
|
+
'bili.announce.unverified': 'B 站收下了这条评论({bvid} · {tag}),但公开列表里读不到——可能还在审核,也可能已被移除。要重发就用【仍然发送】。',
|
|
430
|
+
'bili.visible.false': '已发送但读不到',
|
|
431
|
+
'bili.visible.hint': 'B 站接受了这条评论,但公开列表里读不到它(B 站原话:{code})。审核中的和被移除的,从接口上看一模一样。可以用【仍然发送】重发。',
|
|
418
432
|
'bili.announce.previewFailed': '预览失败:{reason}',
|
|
419
433
|
'bili.repo.none': '这个仓库还没有绑定 B 站视频。',
|
|
420
434
|
'bili.state.already': '{tag} 已经播报过了。',
|
|
@@ -534,6 +548,14 @@ window.__ModuleLoader__.load({
|
|
|
534
548
|
'meta.version': 'local',
|
|
535
549
|
'meta.expectedTag': 'expected tag {tag}',
|
|
536
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}.',
|
|
537
559
|
'meta.assets': '{count} assets',
|
|
538
560
|
'meta.path': 'Path',
|
|
539
561
|
'meta.scopes': 'Scopes',
|
|
@@ -711,8 +733,10 @@ window.__ModuleLoader__.load({
|
|
|
711
733
|
'bili.credential.source': 'Credential file {path}',
|
|
712
734
|
'bili.credential.external': 'This credential comes from the external file named in the config, not from this panel: {path}',
|
|
713
735
|
'bili.signIn': 'Sign in to Bilibili',
|
|
714
|
-
'bili.signIn.hint': 'One click produces a
|
|
715
|
-
'bili.
|
|
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.',
|
|
716
740
|
'bili.waiting': 'Waiting to be scanned…',
|
|
717
741
|
'bili.waitingScanned': 'Scanned — confirm it on your phone…',
|
|
718
742
|
'bili.signIn.done': 'Signed in: {uname}',
|
|
@@ -735,6 +759,7 @@ window.__ModuleLoader__.load({
|
|
|
735
759
|
'bili.bind.placeholder': 'BV id, e.g. BV1RopP6FEJp',
|
|
736
760
|
'bili.bind.save': 'Bind',
|
|
737
761
|
'bili.bind.unbind': 'Unbind',
|
|
762
|
+
'bili.bind.locked': 'Bound: press 【Unbind】 first to change the video.',
|
|
738
763
|
'bili.bind.done': 'Bound {bvid}{baseline}',
|
|
739
764
|
'bili.bind.baseline': ' (baseline {tag}: only releases published after this are announced automatically)',
|
|
740
765
|
'bili.bind.baselineUnknown': ' (the release list could not be read, so everything published before now counts as already public)',
|
|
@@ -748,7 +773,10 @@ window.__ModuleLoader__.load({
|
|
|
748
773
|
'bili.announce.confirm': 'Post it',
|
|
749
774
|
'bili.announce.force': 'Post anyway',
|
|
750
775
|
'bili.announce.cancel': 'Cancel',
|
|
751
|
-
'bili.announce.sent': 'Posted to the comments under {bvid} ({tag}).',
|
|
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.',
|
|
752
780
|
'bili.announce.previewFailed': 'Preview failed: {reason}',
|
|
753
781
|
'bili.repo.none': 'This repository has no video bound.',
|
|
754
782
|
'bili.state.already': '{tag} has already been announced.',
|
|
@@ -857,6 +885,16 @@ window.__ModuleLoader__.load({
|
|
|
857
885
|
/* The commit confirmation: a sentence, the file list, a message field, then the two
|
|
858
886
|
buttons. A column, because the list wraps and a row would push the buttons off. */
|
|
859
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; }
|
|
860
898
|
/* The console's own segment strip, inside its one Plugins tab. Left-aligned and with a
|
|
861
899
|
rule under it, so it reads as "which part of this page" rather than as a second set
|
|
862
900
|
of tabs floating in the middle: the dsc-bar rule alone would spread three buttons
|
|
@@ -1383,7 +1421,7 @@ window.__ModuleLoader__.load({
|
|
|
1383
1421
|
|
|
1384
1422
|
/** One repository row, expandable into path, releases, runs and logs. */
|
|
1385
1423
|
function RepoRow(props) {
|
|
1386
|
-
const { t, data, busy, onAction, onBump, onCommit, onPublish, onUpdate, npm, onNpmPublish, bili, onBiliBind, onBiliPreview, onBiliAnnounce, logs, onLogs } = props
|
|
1424
|
+
const { t, data, busy, onAction, onBump, onCommit, onClone, onPublish, onUpdate, npm, onNpmPublish, bili, onBiliBind, onBiliPreview, onBiliAnnounce, logs, onLogs } = props
|
|
1387
1425
|
const [open, setOpen] = React.useState(false)
|
|
1388
1426
|
/**
|
|
1389
1427
|
* A draft release is the panel's one invisible outcome: it exists, it is not
|
|
@@ -1632,9 +1670,18 @@ window.__ModuleLoader__.load({
|
|
|
1632
1670
|
? h(
|
|
1633
1671
|
'div',
|
|
1634
1672
|
{ className: 'dsc-detail' },
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
+
}),
|
|
1638
1685
|
data.problems.length > 0 ? h('div', { className: 'dsc-warn' }, data.problems.join(' · ')) : null,
|
|
1639
1686
|
|
|
1640
1687
|
/* Why 发布 cannot work, and what would make it work. Both notes are
|
|
@@ -2244,6 +2291,35 @@ window.__ModuleLoader__.load({
|
|
|
2244
2291
|
[loadOverview, setError, t],
|
|
2245
2292
|
)
|
|
2246
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
|
+
|
|
2247
2323
|
/**
|
|
2248
2324
|
* Install the release into this profile, then ask about the restart.
|
|
2249
2325
|
*
|
|
@@ -2490,7 +2566,14 @@ window.__ModuleLoader__.load({
|
|
|
2490
2566
|
return null
|
|
2491
2567
|
}
|
|
2492
2568
|
const value = result.payload.value ?? {}
|
|
2493
|
-
|
|
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 ?? '') }))
|
|
2494
2577
|
await loadBili({ force: true })
|
|
2495
2578
|
return value
|
|
2496
2579
|
},
|
|
@@ -2774,6 +2857,7 @@ window.__ModuleLoader__.load({
|
|
|
2774
2857
|
onAction,
|
|
2775
2858
|
onBump: () => onBump(data),
|
|
2776
2859
|
onCommit: (row, message) => { void onCommit(row, message) },
|
|
2860
|
+
onClone: (row, url) => { void onClone(row, url) },
|
|
2777
2861
|
onPublish: (tag) => onPublish(data.repo, tag),
|
|
2778
2862
|
onUpdate: () => { void onUpdate(data) },
|
|
2779
2863
|
npm: npmByRepo.get(data.repo) ?? null,
|
|
@@ -2795,6 +2879,7 @@ window.__ModuleLoader__.load({
|
|
|
2795
2879
|
onAction,
|
|
2796
2880
|
onBump: () => {},
|
|
2797
2881
|
onCommit: () => {},
|
|
2882
|
+
onClone: () => {},
|
|
2798
2883
|
onPublish: () => {},
|
|
2799
2884
|
logs: null,
|
|
2800
2885
|
onLogs: async () => {},
|
|
@@ -3142,6 +3227,12 @@ window.__ModuleLoader__.load({
|
|
|
3142
3227
|
}, [confirmingSignOut, onChanged, post, setNotice, t])
|
|
3143
3228
|
|
|
3144
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
|
+
)
|
|
3145
3236
|
React.useEffect(() => {
|
|
3146
3237
|
if (!running) return undefined
|
|
3147
3238
|
let cancelled = false
|
|
@@ -3170,6 +3261,13 @@ window.__ModuleLoader__.load({
|
|
|
3170
3261
|
|
|
3171
3262
|
if (credential === null) return null
|
|
3172
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'
|
|
3173
3271
|
const stateText = (() => {
|
|
3174
3272
|
if (state === 'ready') {
|
|
3175
3273
|
return credential.account?.uname
|
|
@@ -3204,62 +3302,166 @@ window.__ModuleLoader__.load({
|
|
|
3204
3302
|
? h('span', { className: 'dsc-quiet' }, t('bili.credential.external', { path: String(credential.configuredPath ?? '') }))
|
|
3205
3303
|
: null,
|
|
3206
3304
|
|
|
3207
|
-
|
|
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
|
|
3208
3356
|
? h(
|
|
3209
3357
|
'div',
|
|
3210
3358
|
{ className: 'dsc-setup-line' },
|
|
3211
|
-
h(
|
|
3212
|
-
login.url
|
|
3213
|
-
? h(Btn, { disabled: busy !== '', onClick: () => globalThis.open(String(login.url), '_blank', 'noopener,noreferrer') }, t('bili.openLogin'))
|
|
3214
|
-
: null,
|
|
3215
|
-
h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void cancel() } }, t('bili.cancelSignIn')),
|
|
3359
|
+
h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void signOut() } }, confirmingSignOut ? t('bili.signOut.confirm') : t('bili.signOut')),
|
|
3216
3360
|
)
|
|
3361
|
+
: null,
|
|
3362
|
+
|
|
3363
|
+
ready
|
|
3364
|
+
? null
|
|
3217
3365
|
: h(
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
h(
|
|
3221
|
-
h('span', { className: 'dsc-quiet' }, t('bili.
|
|
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')),
|
|
3222
3370
|
),
|
|
3223
|
-
|
|
3224
|
-
?
|
|
3371
|
+
ready
|
|
3372
|
+
? null
|
|
3373
|
+
: h(
|
|
3225
3374
|
'div',
|
|
3226
3375
|
{ className: 'dsc-setup-line' },
|
|
3227
|
-
h(
|
|
3228
|
-
|
|
3229
|
-
|
|
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
|
+
}
|
|
3230
3405
|
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
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')),
|
|
3262
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 })),
|
|
3263
3465
|
)
|
|
3264
3466
|
}
|
|
3265
3467
|
|
|
@@ -3321,23 +3523,33 @@ window.__ModuleLoader__.load({
|
|
|
3321
3523
|
h(
|
|
3322
3524
|
'div',
|
|
3323
3525
|
{ className: 'dsc-line' },
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
}),
|
|
3333
|
-
h(Btn, {
|
|
3334
|
-
kind: binding === null ? 'primary' : undefined,
|
|
3335
|
-
disabled: busy !== '' || draft.trim() === '',
|
|
3336
|
-
onClick: () => { void onBind(data, draft.trim(), binding === null ? true : binding.auto !== false) },
|
|
3337
|
-
}, busy === `bili-bind:${data.repo}` ? '…' : t('bili.bind.save')),
|
|
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
|
+
*/
|
|
3338
3534
|
binding === null
|
|
3339
|
-
?
|
|
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'))
|
|
3340
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')),
|
|
3341
3553
|
h('span', { className: 'dsc-grow' }, binding === null
|
|
3342
3554
|
? t('bili.repo.none')
|
|
3343
3555
|
: bili?.video?.ok === true
|
|
@@ -3354,6 +3566,12 @@ window.__ModuleLoader__.load({
|
|
|
3354
3566
|
? t('bili.announced.none')
|
|
3355
3567
|
: `${t('bili.chip.announced', { tag: String(last.tag) })}${last.url ? '' : ''}`),
|
|
3356
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,
|
|
3357
3575
|
last !== null && typeof last.url === 'string' && last.url !== ''
|
|
3358
3576
|
? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(last.url), '_blank', 'noopener,noreferrer') }, t('action.open'))
|
|
3359
3577
|
: null,
|
|
@@ -3425,6 +3643,419 @@ window.__ModuleLoader__.load({
|
|
|
3425
3643
|
* is configuration plus a public record, and belongs beside the two credential
|
|
3426
3644
|
* pages that already live here.
|
|
3427
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
|
+
|
|
3428
4059
|
function BilibiliPage(props) {
|
|
3429
4060
|
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
3430
4061
|
const { bili, error, setError, loadBili } = useConsoleState()
|