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/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': '点一下会生成一个登录链接:用手机 B 站扫码,或在你已经登录 B 站的浏览器里打开并确认。这里会自动继续,不需要终端。',
381
- 'bili.openLogin': '打开登录页面',
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 sign-in link: scan it with the Bilibili app, or open it in a browser that is already signed in and confirm. This page continues by itself — no terminal needed.',
715
- 'bili.openLogin': 'Open the sign-in page',
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
- data.localPath === ''
1636
- ? h('span', null, t('meta.noLocal'))
1637
- : h('span', { className: 'dsc-mono' }, `${t('meta.path')} ${data.localPath}`),
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
- setNotice(t('bili.announce.sent', { bvid: String(value.bvid ?? ''), tag: String(value.tag ?? '') }))
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
- running
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('span', { className: 'dsc-grow' }, login.state === 'scanned' ? t('bili.waitingScanned') : t('bili.waiting')),
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
- 'div',
3219
- { className: 'dsc-setup-line' },
3220
- h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void start() } }, busy === 'bili-login' ? '…' : t('bili.signIn')),
3221
- h('span', { className: 'dsc-quiet' }, t('bili.signIn.hint')),
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
- state === 'ready'
3224
- ? h(
3371
+ ready
3372
+ ? null
3373
+ : h(
3225
3374
  'div',
3226
3375
  { className: 'dsc-setup-line' },
3227
- h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void signOut() } }, confirmingSignOut ? t('bili.signOut.confirm') : t('bili.signOut')),
3228
- )
3229
- : null,
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
- h('span', { className: 'dsc-setup-strong' }, t('bili.paste.title')),
3232
- h('span', { className: 'dsc-quiet' }, t('bili.paste.hint')),
3233
- h(
3234
- 'div',
3235
- { className: 'dsc-setup-line' },
3236
- h('input', {
3237
- className: 'dsc-input',
3238
- type: 'password',
3239
- autoComplete: 'off',
3240
- spellCheck: 'false',
3241
- placeholder: t('bili.paste.sessdata'),
3242
- 'aria-label': t('bili.paste.sessdata'),
3243
- value: sessdata,
3244
- onChange: (event) => setSessdata(String(event?.target?.value ?? '')),
3245
- }),
3246
- h('input', {
3247
- className: 'dsc-input',
3248
- type: 'password',
3249
- autoComplete: 'off',
3250
- spellCheck: 'false',
3251
- placeholder: t('bili.paste.csrf'),
3252
- 'aria-label': t('bili.paste.csrf'),
3253
- value: csrf,
3254
- onChange: (event) => setCsrf(String(event?.target?.value ?? '')),
3255
- }),
3256
- h(Btn, {
3257
- kind: 'primary',
3258
- disabled: busy !== '' || sessdata.trim() === '' || csrf.trim() === '',
3259
- onClick: () => { void save() },
3260
- }, busy === 'bili-paste' ? '…' : t('bili.paste.save')),
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
- h('input', {
3325
- className: 'dsc-input',
3326
- type: 'text',
3327
- spellCheck: 'false',
3328
- placeholder: t('bili.bind.placeholder'),
3329
- 'aria-label': t('bili.bind.placeholder'),
3330
- value: draft,
3331
- onChange: (event) => setDraft(String(event?.target?.value ?? '').trim()),
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
- ? 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'))
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()