dsh-qr-share 0.2.0 → 0.3.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/CHANGELOG.md +47 -1
- package/cordis.patch.yml +6 -4
- package/lib/client-registry.js +157 -19
- package/lib/client.js +159 -21
- package/lib/types/client/index.d.ts +1 -0
- package/lib/types/client/locales.d.ts +15 -2
- package/package.json +1 -3
- package/src/client/QrDialog.test.tsx +24 -0
- package/src/client/QrDialog.tsx +58 -5
- package/src/client/index.tsx +14 -2
- package/src/client/locales.ts +101 -2
- package/src/client/styles.module.css +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.1] — 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Trimmed `dsh.client.inject` peer-module list** — dropped
|
|
15
|
+
`@deepseek-ai/dsh-client-runtime` and `@deepseek-ai/dsh-client-ui-slots`
|
|
16
|
+
from the package.json manifest. The plugin's runtime only consumes
|
|
17
|
+
three services from the dsh host (`slots`, `locale`, `connection`),
|
|
18
|
+
and these are still provided by the remaining two peer modules
|
|
19
|
+
(`@deepseek-ai/dsh-client-locale` and
|
|
20
|
+
`@deepseek-ai/dsh-client-ui-sidebar`). Boot-time module manifest
|
|
21
|
+
shrinks and `__DSH_BOOT__.clientModules` reflects the smaller
|
|
22
|
+
surface; user-visible behavior (sidebar footer button, QR dialog,
|
|
23
|
+
copy-link, six extra locales) is unchanged.
|
|
24
|
+
|
|
25
|
+
## [0.3.0] — 2026-08-30
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Copy link button in the dialog** — a second footer action next to
|
|
30
|
+
Close. Clicking it copies `${window.location.origin}/?token=…` to
|
|
31
|
+
the clipboard; the label flips to a localized "已复制 / Copied"
|
|
32
|
+
confirmation for 1.5 s, then back. Falls back from the async
|
|
33
|
+
Clipboard API to `document.execCommand('copy')` when the secure-
|
|
34
|
+
context path is unavailable (older WebViews, non-HTTPS previews),
|
|
35
|
+
and surfaces "复制失败 / Copy failed" if both paths reject.
|
|
36
|
+
- **Extra locale dictionaries** — beyond the built-in `zh` and `en`,
|
|
37
|
+
six more locales are registered via the single-locale
|
|
38
|
+
`ctx.locale.register(ns, locale, dict)` overload: `zh-Hant` (繁體),
|
|
39
|
+
`ja`, `ko`, `fr`, `de`, `ru`. They activate only when a matching
|
|
40
|
+
language-pack plugin sets the DSH preference; without a language
|
|
41
|
+
pack, the existing fallback chain (`zh` → `en`) still resolves.
|
|
42
|
+
- **Dialog keys for the copy flow** — `dialog.copy`, `dialog.copied`,
|
|
43
|
+
and `dialog.copyFailed` added to the `QrShareKey` interface and
|
|
44
|
+
every dictionary.
|
|
45
|
+
- **Two QrDialog tests** — clipboard write is asserted with a
|
|
46
|
+
stubbed `navigator.clipboard.writeText` (success + reject path).
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **Footer action row** in `QrDialog` now holds two buttons
|
|
51
|
+
("Copy link" + "Close") in a flex row (`.actions`) instead of a
|
|
52
|
+
single Close button. The `.close` class is generalized to `.action`;
|
|
53
|
+
the dialog's visual rhythm is unchanged.
|
|
54
|
+
|
|
10
55
|
## [0.2.0] — 2026-08-30
|
|
11
56
|
|
|
12
57
|
### Added
|
|
@@ -102,5 +147,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
102
147
|
preventing cross-site frames from reaching the route and stopping
|
|
103
148
|
intermediary caches from storing the token.
|
|
104
149
|
|
|
105
|
-
[Unreleased]: https://github.com/xiaoguomeiyitian/dsh-qr-share/compare/v0.
|
|
150
|
+
[Unreleased]: https://github.com/xiaoguomeiyitian/dsh-qr-share/compare/v0.2.0...HEAD
|
|
151
|
+
[0.2.0]: https://github.com/xiaoguomeiyitian/dsh-qr-share/compare/v0.1.0...v0.2.0
|
|
106
152
|
[0.1.0]: https://github.com/xiaoguomeiyitian/dsh-qr-share/releases/tag/v0.1.0
|
package/cordis.patch.yml
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
# modules resolves `<name>/package.json` from the profile dir to
|
|
15
15
|
# discover the client half and compose window.__DSH_BOOT__.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# dsh-
|
|
17
|
+
# `id` MUST match the bundle's own `window.__ModuleLoader__.load({id,
|
|
18
|
+
# …})` argument (set by `tsdown.config.ts#clientBundle(pluginId)`.).
|
|
19
|
+
# An id mismatch breaks dsh-client-modules' module graph: the loader
|
|
20
|
+
# entry path and the bundle's required id diverge, so the browser
|
|
21
|
+
# never resolves the plugin and shows nothing on screen.
|
|
20
22
|
- insert:
|
|
21
|
-
- id: qr-share
|
|
23
|
+
- id: dsh-qr-share
|
|
22
24
|
name: 'dsh-qr-share'
|
package/lib/client-registry.js
CHANGED
|
@@ -2579,8 +2579,12 @@ window.__ModuleLoader__.load({
|
|
|
2579
2579
|
});
|
|
2580
2580
|
})))(), 1);
|
|
2581
2581
|
/**
|
|
2582
|
-
* dsh-qr-share dictionaries
|
|
2583
|
-
*
|
|
2582
|
+
* dsh-qr-share dictionaries. The DSH shell ships `zh` + `en` as built-in
|
|
2583
|
+
* locales and falls back through its language chain; additional
|
|
2584
|
+
* dictionaries below (`zh-Hant`, `ja`, `ko`, `fr`, `de`, `ru`) register
|
|
2585
|
+
* through the single-locale overload and activate whenever a language
|
|
2586
|
+
* pack sets that preference. Unregistered locales still fall back to
|
|
2587
|
+
* the built-in chain (`zh`/`en`).
|
|
2584
2588
|
*/
|
|
2585
2589
|
const LOCALE_NS = "qr-share";
|
|
2586
2590
|
const zh = {
|
|
@@ -2589,6 +2593,9 @@ window.__ModuleLoader__.load({
|
|
|
2589
2593
|
"dialog.title": "扫码分享",
|
|
2590
2594
|
"dialog.hint": "用手机相机扫描下方二维码。手机打开后会自动以当前账号登录,token 与浏览器 cookie 同寿命。",
|
|
2591
2595
|
"dialog.urlLabel": "链接",
|
|
2596
|
+
"dialog.copy": "复制链接",
|
|
2597
|
+
"dialog.copied": "已复制",
|
|
2598
|
+
"dialog.copyFailed": "复制失败",
|
|
2592
2599
|
"dialog.close": "关闭",
|
|
2593
2600
|
"dialog.error": "二维码生成失败"
|
|
2594
2601
|
};
|
|
@@ -2598,12 +2605,94 @@ window.__ModuleLoader__.load({
|
|
|
2598
2605
|
"dialog.title": "Scan to share",
|
|
2599
2606
|
"dialog.hint": "Scan the QR code below with your phone camera. The phone will log in to this session; the token lives as long as the browser cookie.",
|
|
2600
2607
|
"dialog.urlLabel": "URL",
|
|
2608
|
+
"dialog.copy": "Copy link",
|
|
2609
|
+
"dialog.copied": "Copied",
|
|
2610
|
+
"dialog.copyFailed": "Copy failed",
|
|
2601
2611
|
"dialog.close": "Close",
|
|
2602
2612
|
"dialog.error": "Failed to render QR code"
|
|
2603
2613
|
};
|
|
2614
|
+
/**
|
|
2615
|
+
* Extra dictionaries for non-built-in locales. These ride the
|
|
2616
|
+
* single-locale registration overload; a matching locale only activates
|
|
2617
|
+
* when a language-pack plugin sets the preference.
|
|
2618
|
+
*/
|
|
2619
|
+
const extraDicts = {
|
|
2620
|
+
"zh-Hant": {
|
|
2621
|
+
"button.label": "分享",
|
|
2622
|
+
"button.tooltip": "掃碼將目前工作階段分享到手機",
|
|
2623
|
+
"dialog.title": "掃碼分享",
|
|
2624
|
+
"dialog.hint": "用手機相機掃描下方 QR Code。手機開啟後會自動以目前帳號登入,token 與瀏覽器 cookie 同壽命。",
|
|
2625
|
+
"dialog.urlLabel": "連結",
|
|
2626
|
+
"dialog.copy": "複製連結",
|
|
2627
|
+
"dialog.copied": "已複製",
|
|
2628
|
+
"dialog.copyFailed": "複製失敗",
|
|
2629
|
+
"dialog.close": "關閉",
|
|
2630
|
+
"dialog.error": "QR Code 產生失敗"
|
|
2631
|
+
},
|
|
2632
|
+
ja: {
|
|
2633
|
+
"button.label": "共有",
|
|
2634
|
+
"button.tooltip": "スキャンしてこのセッションをスマホに共有",
|
|
2635
|
+
"dialog.title": "スキャンして共有",
|
|
2636
|
+
"dialog.hint": "スマホのカメラで下のQRコードを読み取ってください。そのセッションに自動でログインし、トークンの有効期限はブラウザのCookieと同じです。",
|
|
2637
|
+
"dialog.urlLabel": "リンク",
|
|
2638
|
+
"dialog.copy": "リンクをコピー",
|
|
2639
|
+
"dialog.copied": "コピーしました",
|
|
2640
|
+
"dialog.copyFailed": "コピーに失敗しました",
|
|
2641
|
+
"dialog.close": "閉じる",
|
|
2642
|
+
"dialog.error": "QRコードの生成に失敗しました"
|
|
2643
|
+
},
|
|
2644
|
+
ko: {
|
|
2645
|
+
"button.label": "공유",
|
|
2646
|
+
"button.tooltip": "스캔하여 현재 세션을 휴대폰으로 공유",
|
|
2647
|
+
"dialog.title": "스캔하여 공유",
|
|
2648
|
+
"dialog.hint": "휴대폰 카메라로 아래 QR 코드를 스캔하세요. 휴대폰이 이 세션에 자동으로 로그인되며, 토큰은 브라우저 쿠키와 같은 수명을 가집니다.",
|
|
2649
|
+
"dialog.urlLabel": "링크",
|
|
2650
|
+
"dialog.copy": "링크 복사",
|
|
2651
|
+
"dialog.copied": "복사됨",
|
|
2652
|
+
"dialog.copyFailed": "복사 실패",
|
|
2653
|
+
"dialog.close": "닫기",
|
|
2654
|
+
"dialog.error": "QR 코드 생성 실패"
|
|
2655
|
+
},
|
|
2656
|
+
fr: {
|
|
2657
|
+
"button.label": "Partager",
|
|
2658
|
+
"button.tooltip": "Scannez pour partager cette session avec votre téléphone",
|
|
2659
|
+
"dialog.title": "Scanner pour partager",
|
|
2660
|
+
"dialog.hint": "Scannez le QR code ci-dessous avec l'appareil photo de votre téléphone. Le téléphone se connectera à cette session ; le jeton a la même durée de vie que le cookie du navigateur.",
|
|
2661
|
+
"dialog.urlLabel": "Lien",
|
|
2662
|
+
"dialog.copy": "Copier le lien",
|
|
2663
|
+
"dialog.copied": "Copié",
|
|
2664
|
+
"dialog.copyFailed": "Échec de la copie",
|
|
2665
|
+
"dialog.close": "Fermer",
|
|
2666
|
+
"dialog.error": "Échec de la génération du QR code"
|
|
2667
|
+
},
|
|
2668
|
+
de: {
|
|
2669
|
+
"button.label": "Teilen",
|
|
2670
|
+
"button.tooltip": "Scannen, um diese Sitzung mit dem Telefon zu teilen",
|
|
2671
|
+
"dialog.title": "Scannen zum Teilen",
|
|
2672
|
+
"dialog.hint": "Scannen Sie den untenstehenden QR-Code mit der Kamera Ihres Telefons. Das Telefon meldet sich an dieser Sitzung an; das Token lebt so lange wie das Browser-Cookie.",
|
|
2673
|
+
"dialog.urlLabel": "Link",
|
|
2674
|
+
"dialog.copy": "Link kopieren",
|
|
2675
|
+
"dialog.copied": "Kopiert",
|
|
2676
|
+
"dialog.copyFailed": "Kopieren fehlgeschlagen",
|
|
2677
|
+
"dialog.close": "Schließen",
|
|
2678
|
+
"dialog.error": "QR-Code konnte nicht erstellt werden"
|
|
2679
|
+
},
|
|
2680
|
+
ru: {
|
|
2681
|
+
"button.label": "Поделиться",
|
|
2682
|
+
"button.tooltip": "Отсканируйте, чтобы поделиться этой сессией с телефоном",
|
|
2683
|
+
"dialog.title": "Отсканируйте для доступа",
|
|
2684
|
+
"dialog.hint": "Отсканируйте QR-код ниже камерой телефона. Телефон войдёт в эту сессию; токен живёт столько же, сколько cookie браузера.",
|
|
2685
|
+
"dialog.urlLabel": "Ссылка",
|
|
2686
|
+
"dialog.copy": "Копировать ссылку",
|
|
2687
|
+
"dialog.copied": "Скопировано",
|
|
2688
|
+
"dialog.copyFailed": "Не удалось скопировать",
|
|
2689
|
+
"dialog.close": "Закрыть",
|
|
2690
|
+
"dialog.error": "Не удалось создать QR-код"
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2604
2693
|
//#endregion
|
|
2605
2694
|
//#region \0dsh-css:/root/ai/dsh-qr-share/src/client/styles.module.css.mjs
|
|
2606
|
-
const css = ".GjS9-q_button{width:100%;color:inherit;font:inherit;cursor:pointer;text-align:left;background:0 0;border:0;border-radius:6px;align-items:center;gap:8px;padding:8px 10px;display:inline-flex}.GjS9-q_button:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_button:focus-visible{outline-offset:-2px;outline:2px solid}.GjS9-q_buttonLabel{white-space:nowrap;text-overflow:ellipsis;font-size:13px;overflow:hidden}.GjS9-q_backdrop{z-index:9999;background:#00000073;place-items:center;animation:.12s ease-out GjS9-q_dsh-qr-share-fade-in;display:grid;position:fixed;inset:0}.GjS9-q_dialog{background:var(--dsh-qr-share-dialog-bg,var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-overlay,#2c2c2e)));color:var(--dsh-qr-share-dialog-fg,currentColor);border:1px solid color-mix(in srgb, currentColor 12%, transparent);border-radius:12px;flex-direction:column;align-items:center;gap:12px;min-width:320px;max-width:420px;padding:24px;animation:.16s cubic-bezier(.2,.9,.3,1.2) GjS9-q_dsh-qr-share-pop-in;display:flex;box-shadow:0 12px 32px #00000040}.GjS9-q_title{margin:0;font-size:16px;font-weight:600}.GjS9-q_canvas{image-rendering:pixelated;background:#fff;border-radius:8px;padding:8px;display:block}.GjS9-q_err{color:#d23f3f;margin:0;font-size:13px}.GjS9-q_urlBlock{flex-direction:column;align-items:center;gap:4px;width:100%;display:flex}.GjS9-q_urlLabel{text-transform:uppercase;letter-spacing:.05em;opacity:.6;font-size:11px}.GjS9-q_url{word-break:break-all;background:color-mix(in srgb, currentColor 6%, transparent);text-align:center;border-radius:4px;max-width:100%;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.GjS9-q_hint{opacity:.7;text-align:center;margin:0;font-size:12px}.GjS9-
|
|
2695
|
+
const css = ".GjS9-q_button{width:100%;color:inherit;font:inherit;cursor:pointer;text-align:left;background:0 0;border:0;border-radius:6px;align-items:center;gap:8px;padding:8px 10px;display:inline-flex}.GjS9-q_button:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_button:focus-visible{outline-offset:-2px;outline:2px solid}.GjS9-q_buttonLabel{white-space:nowrap;text-overflow:ellipsis;font-size:13px;overflow:hidden}.GjS9-q_backdrop{z-index:9999;background:#00000073;place-items:center;animation:.12s ease-out GjS9-q_dsh-qr-share-fade-in;display:grid;position:fixed;inset:0}.GjS9-q_dialog{background:var(--dsh-qr-share-dialog-bg,var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-overlay,#2c2c2e)));color:var(--dsh-qr-share-dialog-fg,currentColor);border:1px solid color-mix(in srgb, currentColor 12%, transparent);border-radius:12px;flex-direction:column;align-items:center;gap:12px;min-width:320px;max-width:420px;padding:24px;animation:.16s cubic-bezier(.2,.9,.3,1.2) GjS9-q_dsh-qr-share-pop-in;display:flex;box-shadow:0 12px 32px #00000040}.GjS9-q_title{margin:0;font-size:16px;font-weight:600}.GjS9-q_canvas{image-rendering:pixelated;background:#fff;border-radius:8px;padding:8px;display:block}.GjS9-q_err{color:#d23f3f;margin:0;font-size:13px}.GjS9-q_urlBlock{flex-direction:column;align-items:center;gap:4px;width:100%;display:flex}.GjS9-q_urlLabel{text-transform:uppercase;letter-spacing:.05em;opacity:.6;font-size:11px}.GjS9-q_url{word-break:break-all;background:color-mix(in srgb, currentColor 6%, transparent);text-align:center;border-radius:4px;max-width:100%;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.GjS9-q_hint{opacity:.7;text-align:center;margin:0;font-size:12px}.GjS9-q_actions{gap:8px;margin-top:4px;display:flex}.GjS9-q_action{border:1px solid color-mix(in srgb, currentColor 20%, transparent);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:6px;padding:8px 16px}.GjS9-q_action:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_action:focus-visible{outline-offset:-2px;outline:2px solid}@keyframes GjS9-q_dsh-qr-share-fade-in{0%{opacity:0}to{opacity:1}}@keyframes GjS9-q_dsh-qr-share-pop-in{0%{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}";
|
|
2607
2696
|
const tagId = "dsh-external/dsh-qr-share/styles.module.css";
|
|
2608
2697
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
2609
2698
|
const tag = document.createElement("style");
|
|
@@ -2613,20 +2702,21 @@ window.__ModuleLoader__.load({
|
|
|
2613
2702
|
document.head.appendChild(tag);
|
|
2614
2703
|
}
|
|
2615
2704
|
var styles_module_css_default = {
|
|
2616
|
-
"title": "GjS9-q_title",
|
|
2617
|
-
"urlBlock": "GjS9-q_urlBlock",
|
|
2618
2705
|
"buttonLabel": "GjS9-q_buttonLabel",
|
|
2706
|
+
"urlBlock": "GjS9-q_urlBlock",
|
|
2707
|
+
"actions": "GjS9-q_actions",
|
|
2708
|
+
"dsh-qr-share-fade-in": "GjS9-q_dsh-qr-share-fade-in",
|
|
2709
|
+
"title": "GjS9-q_title",
|
|
2619
2710
|
"err": "GjS9-q_err",
|
|
2620
|
-
"
|
|
2621
|
-
"url": "GjS9-q_url",
|
|
2711
|
+
"action": "GjS9-q_action",
|
|
2622
2712
|
"hint": "GjS9-q_hint",
|
|
2623
|
-
"
|
|
2713
|
+
"canvas": "GjS9-q_canvas",
|
|
2714
|
+
"urlLabel": "GjS9-q_urlLabel",
|
|
2624
2715
|
"dsh-qr-share-pop-in": "GjS9-q_dsh-qr-share-pop-in",
|
|
2625
|
-
"close": "GjS9-q_close",
|
|
2626
|
-
"dialog": "GjS9-q_dialog",
|
|
2627
|
-
"dsh-qr-share-fade-in": "GjS9-q_dsh-qr-share-fade-in",
|
|
2628
2716
|
"button": "GjS9-q_button",
|
|
2629
|
-
"
|
|
2717
|
+
"url": "GjS9-q_url",
|
|
2718
|
+
"dialog": "GjS9-q_dialog",
|
|
2719
|
+
"backdrop": "GjS9-q_backdrop"
|
|
2630
2720
|
};
|
|
2631
2721
|
//#endregion
|
|
2632
2722
|
//#region src/client/QrDialog.tsx
|
|
@@ -2654,6 +2744,30 @@ window.__ModuleLoader__.load({
|
|
|
2654
2744
|
url.searchParams.set("token", token);
|
|
2655
2745
|
return url.toString();
|
|
2656
2746
|
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Write text to the clipboard. Prefers the async Clipboard API; falls
|
|
2749
|
+
* back to the legacy execCommand path for older WebViews / non-secure
|
|
2750
|
+
* contexts where navigator.clipboard is absent.
|
|
2751
|
+
*/
|
|
2752
|
+
async function copyTextToClipboard(text) {
|
|
2753
|
+
if (typeof navigator !== "undefined" && navigator.clipboard !== void 0) try {
|
|
2754
|
+
await navigator.clipboard.writeText(text);
|
|
2755
|
+
return true;
|
|
2756
|
+
} catch {}
|
|
2757
|
+
try {
|
|
2758
|
+
const area = document.createElement("textarea");
|
|
2759
|
+
area.value = text;
|
|
2760
|
+
area.style.position = "fixed";
|
|
2761
|
+
area.style.opacity = "0";
|
|
2762
|
+
document.body.appendChild(area);
|
|
2763
|
+
area.select();
|
|
2764
|
+
const ok = document.execCommand("copy");
|
|
2765
|
+
document.body.removeChild(area);
|
|
2766
|
+
return ok;
|
|
2767
|
+
} catch {
|
|
2768
|
+
return false;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2657
2771
|
function QrDialog({ token, tokenTtlMs, t, onClose }) {
|
|
2658
2772
|
const canvasRef = (0, react.useRef)(null);
|
|
2659
2773
|
const [payload, setPayload] = (0, react.useState)("");
|
|
@@ -2680,6 +2794,21 @@ window.__ModuleLoader__.load({
|
|
|
2680
2794
|
setError(reason instanceof Error ? reason.message : String(reason));
|
|
2681
2795
|
});
|
|
2682
2796
|
}, [token, portalTarget]);
|
|
2797
|
+
const [copyState, setCopyState] = (0, react.useState)("idle");
|
|
2798
|
+
(0, react.useEffect)(() => {
|
|
2799
|
+
if (copyState === "idle") return;
|
|
2800
|
+
const timer = setTimeout(() => {
|
|
2801
|
+
setCopyState("idle");
|
|
2802
|
+
}, 1500);
|
|
2803
|
+
return () => {
|
|
2804
|
+
clearTimeout(timer);
|
|
2805
|
+
};
|
|
2806
|
+
}, [copyState]);
|
|
2807
|
+
const onCopy = () => {
|
|
2808
|
+
copyTextToClipboard(payload !== "" ? payload : composeQrUrl(token)).then((ok) => {
|
|
2809
|
+
setCopyState(ok ? "copied" : "failed");
|
|
2810
|
+
});
|
|
2811
|
+
};
|
|
2683
2812
|
(0, react.useEffect)(() => {
|
|
2684
2813
|
const onKey = (e) => {
|
|
2685
2814
|
if (e.key === "Escape") onClose();
|
|
@@ -2710,11 +2839,15 @@ window.__ModuleLoader__.load({
|
|
|
2710
2839
|
className: styles_module_css_default.canvas,
|
|
2711
2840
|
width: 256,
|
|
2712
2841
|
height: 256
|
|
2713
|
-
}), (0, react.createElement)("div", { className: styles_module_css_default.urlBlock }, (0, react.createElement)("span", { className: styles_module_css_default.urlLabel }, t("dialog.urlLabel")), (0, react.createElement)("code", { className: styles_module_css_default.url }, payload)), (0, react.createElement)("p", { className: styles_module_css_default.hint }, t("dialog.hint")), (0, react.createElement)("button", {
|
|
2842
|
+
}), (0, react.createElement)("div", { className: styles_module_css_default.urlBlock }, (0, react.createElement)("span", { className: styles_module_css_default.urlLabel }, t("dialog.urlLabel")), (0, react.createElement)("code", { className: styles_module_css_default.url }, payload)), (0, react.createElement)("p", { className: styles_module_css_default.hint }, t("dialog.hint")), (0, react.createElement)("div", { className: styles_module_css_default.actions }, (0, react.createElement)("button", {
|
|
2714
2843
|
type: "button",
|
|
2715
|
-
className: styles_module_css_default.
|
|
2844
|
+
className: styles_module_css_default.action,
|
|
2845
|
+
onClick: onCopy
|
|
2846
|
+
}, copyState === "copied" ? t("dialog.copied") : copyState === "failed" ? t("dialog.copyFailed") : t("dialog.copy")), (0, react.createElement)("button", {
|
|
2847
|
+
type: "button",
|
|
2848
|
+
className: styles_module_css_default.action,
|
|
2716
2849
|
onClick: onClose
|
|
2717
|
-
}, t("dialog.close")))), portalTarget);
|
|
2850
|
+
}, t("dialog.close"))))), portalTarget);
|
|
2718
2851
|
}
|
|
2719
2852
|
//#endregion
|
|
2720
2853
|
//#region src/client/QrButton.tsx
|
|
@@ -2823,10 +2956,15 @@ window.__ModuleLoader__.load({
|
|
|
2823
2956
|
* @param ctx - client root context (slots + locale + connection injected).
|
|
2824
2957
|
*/
|
|
2825
2958
|
function apply(ctx) {
|
|
2826
|
-
ctx.effect(() =>
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2959
|
+
ctx.effect(() => {
|
|
2960
|
+
const disposers = [ctx.locale.register(LOCALE_NS, {
|
|
2961
|
+
zh,
|
|
2962
|
+
en
|
|
2963
|
+
}), ...Object.entries(extraDicts).map(([locale, dict]) => ctx.locale.register(LOCALE_NS, locale, dict))];
|
|
2964
|
+
return () => {
|
|
2965
|
+
for (const dispose of disposers) dispose();
|
|
2966
|
+
};
|
|
2967
|
+
}, "qr-share: dictionaries");
|
|
2830
2968
|
const t = ctx.locale.bind(LOCALE_NS);
|
|
2831
2969
|
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
2832
2970
|
name: "sidebar.footer.action",
|
package/lib/client.js
CHANGED
|
@@ -2579,8 +2579,12 @@ window.__ModuleLoader__.load({
|
|
|
2579
2579
|
});
|
|
2580
2580
|
})))(), 1);
|
|
2581
2581
|
/**
|
|
2582
|
-
* dsh-qr-share dictionaries
|
|
2583
|
-
*
|
|
2582
|
+
* dsh-qr-share dictionaries. The DSH shell ships `zh` + `en` as built-in
|
|
2583
|
+
* locales and falls back through its language chain; additional
|
|
2584
|
+
* dictionaries below (`zh-Hant`, `ja`, `ko`, `fr`, `de`, `ru`) register
|
|
2585
|
+
* through the single-locale overload and activate whenever a language
|
|
2586
|
+
* pack sets that preference. Unregistered locales still fall back to
|
|
2587
|
+
* the built-in chain (`zh`/`en`).
|
|
2584
2588
|
*/
|
|
2585
2589
|
const LOCALE_NS = "qr-share";
|
|
2586
2590
|
const zh = {
|
|
@@ -2589,6 +2593,9 @@ window.__ModuleLoader__.load({
|
|
|
2589
2593
|
"dialog.title": "扫码分享",
|
|
2590
2594
|
"dialog.hint": "用手机相机扫描下方二维码。手机打开后会自动以当前账号登录,token 与浏览器 cookie 同寿命。",
|
|
2591
2595
|
"dialog.urlLabel": "链接",
|
|
2596
|
+
"dialog.copy": "复制链接",
|
|
2597
|
+
"dialog.copied": "已复制",
|
|
2598
|
+
"dialog.copyFailed": "复制失败",
|
|
2592
2599
|
"dialog.close": "关闭",
|
|
2593
2600
|
"dialog.error": "二维码生成失败"
|
|
2594
2601
|
};
|
|
@@ -2598,12 +2605,94 @@ window.__ModuleLoader__.load({
|
|
|
2598
2605
|
"dialog.title": "Scan to share",
|
|
2599
2606
|
"dialog.hint": "Scan the QR code below with your phone camera. The phone will log in to this session; the token lives as long as the browser cookie.",
|
|
2600
2607
|
"dialog.urlLabel": "URL",
|
|
2608
|
+
"dialog.copy": "Copy link",
|
|
2609
|
+
"dialog.copied": "Copied",
|
|
2610
|
+
"dialog.copyFailed": "Copy failed",
|
|
2601
2611
|
"dialog.close": "Close",
|
|
2602
2612
|
"dialog.error": "Failed to render QR code"
|
|
2603
2613
|
};
|
|
2614
|
+
/**
|
|
2615
|
+
* Extra dictionaries for non-built-in locales. These ride the
|
|
2616
|
+
* single-locale registration overload; a matching locale only activates
|
|
2617
|
+
* when a language-pack plugin sets the preference.
|
|
2618
|
+
*/
|
|
2619
|
+
const extraDicts = {
|
|
2620
|
+
"zh-Hant": {
|
|
2621
|
+
"button.label": "分享",
|
|
2622
|
+
"button.tooltip": "掃碼將目前工作階段分享到手機",
|
|
2623
|
+
"dialog.title": "掃碼分享",
|
|
2624
|
+
"dialog.hint": "用手機相機掃描下方 QR Code。手機開啟後會自動以目前帳號登入,token 與瀏覽器 cookie 同壽命。",
|
|
2625
|
+
"dialog.urlLabel": "連結",
|
|
2626
|
+
"dialog.copy": "複製連結",
|
|
2627
|
+
"dialog.copied": "已複製",
|
|
2628
|
+
"dialog.copyFailed": "複製失敗",
|
|
2629
|
+
"dialog.close": "關閉",
|
|
2630
|
+
"dialog.error": "QR Code 產生失敗"
|
|
2631
|
+
},
|
|
2632
|
+
ja: {
|
|
2633
|
+
"button.label": "共有",
|
|
2634
|
+
"button.tooltip": "スキャンしてこのセッションをスマホに共有",
|
|
2635
|
+
"dialog.title": "スキャンして共有",
|
|
2636
|
+
"dialog.hint": "スマホのカメラで下のQRコードを読み取ってください。そのセッションに自動でログインし、トークンの有効期限はブラウザのCookieと同じです。",
|
|
2637
|
+
"dialog.urlLabel": "リンク",
|
|
2638
|
+
"dialog.copy": "リンクをコピー",
|
|
2639
|
+
"dialog.copied": "コピーしました",
|
|
2640
|
+
"dialog.copyFailed": "コピーに失敗しました",
|
|
2641
|
+
"dialog.close": "閉じる",
|
|
2642
|
+
"dialog.error": "QRコードの生成に失敗しました"
|
|
2643
|
+
},
|
|
2644
|
+
ko: {
|
|
2645
|
+
"button.label": "공유",
|
|
2646
|
+
"button.tooltip": "스캔하여 현재 세션을 휴대폰으로 공유",
|
|
2647
|
+
"dialog.title": "스캔하여 공유",
|
|
2648
|
+
"dialog.hint": "휴대폰 카메라로 아래 QR 코드를 스캔하세요. 휴대폰이 이 세션에 자동으로 로그인되며, 토큰은 브라우저 쿠키와 같은 수명을 가집니다.",
|
|
2649
|
+
"dialog.urlLabel": "링크",
|
|
2650
|
+
"dialog.copy": "링크 복사",
|
|
2651
|
+
"dialog.copied": "복사됨",
|
|
2652
|
+
"dialog.copyFailed": "복사 실패",
|
|
2653
|
+
"dialog.close": "닫기",
|
|
2654
|
+
"dialog.error": "QR 코드 생성 실패"
|
|
2655
|
+
},
|
|
2656
|
+
fr: {
|
|
2657
|
+
"button.label": "Partager",
|
|
2658
|
+
"button.tooltip": "Scannez pour partager cette session avec votre téléphone",
|
|
2659
|
+
"dialog.title": "Scanner pour partager",
|
|
2660
|
+
"dialog.hint": "Scannez le QR code ci-dessous avec l'appareil photo de votre téléphone. Le téléphone se connectera à cette session ; le jeton a la même durée de vie que le cookie du navigateur.",
|
|
2661
|
+
"dialog.urlLabel": "Lien",
|
|
2662
|
+
"dialog.copy": "Copier le lien",
|
|
2663
|
+
"dialog.copied": "Copié",
|
|
2664
|
+
"dialog.copyFailed": "Échec de la copie",
|
|
2665
|
+
"dialog.close": "Fermer",
|
|
2666
|
+
"dialog.error": "Échec de la génération du QR code"
|
|
2667
|
+
},
|
|
2668
|
+
de: {
|
|
2669
|
+
"button.label": "Teilen",
|
|
2670
|
+
"button.tooltip": "Scannen, um diese Sitzung mit dem Telefon zu teilen",
|
|
2671
|
+
"dialog.title": "Scannen zum Teilen",
|
|
2672
|
+
"dialog.hint": "Scannen Sie den untenstehenden QR-Code mit der Kamera Ihres Telefons. Das Telefon meldet sich an dieser Sitzung an; das Token lebt so lange wie das Browser-Cookie.",
|
|
2673
|
+
"dialog.urlLabel": "Link",
|
|
2674
|
+
"dialog.copy": "Link kopieren",
|
|
2675
|
+
"dialog.copied": "Kopiert",
|
|
2676
|
+
"dialog.copyFailed": "Kopieren fehlgeschlagen",
|
|
2677
|
+
"dialog.close": "Schließen",
|
|
2678
|
+
"dialog.error": "QR-Code konnte nicht erstellt werden"
|
|
2679
|
+
},
|
|
2680
|
+
ru: {
|
|
2681
|
+
"button.label": "Поделиться",
|
|
2682
|
+
"button.tooltip": "Отсканируйте, чтобы поделиться этой сессией с телефоном",
|
|
2683
|
+
"dialog.title": "Отсканируйте для доступа",
|
|
2684
|
+
"dialog.hint": "Отсканируйте QR-код ниже камерой телефона. Телефон войдёт в эту сессию; токен живёт столько же, сколько cookie браузера.",
|
|
2685
|
+
"dialog.urlLabel": "Ссылка",
|
|
2686
|
+
"dialog.copy": "Копировать ссылку",
|
|
2687
|
+
"dialog.copied": "Скопировано",
|
|
2688
|
+
"dialog.copyFailed": "Не удалось скопировать",
|
|
2689
|
+
"dialog.close": "Закрыть",
|
|
2690
|
+
"dialog.error": "Не удалось создать QR-код"
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2604
2693
|
//#endregion
|
|
2605
2694
|
//#region \0dsh-css:/root/ai/dsh-qr-share/src/client/styles.module.css.mjs
|
|
2606
|
-
const css = ".GjS9-q_button{width:100%;color:inherit;font:inherit;cursor:pointer;text-align:left;background:0 0;border:0;border-radius:6px;align-items:center;gap:8px;padding:8px 10px;display:inline-flex}.GjS9-q_button:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_button:focus-visible{outline-offset:-2px;outline:2px solid}.GjS9-q_buttonLabel{white-space:nowrap;text-overflow:ellipsis;font-size:13px;overflow:hidden}.GjS9-q_backdrop{z-index:9999;background:#00000073;place-items:center;animation:.12s ease-out GjS9-q_dsh-qr-share-fade-in;display:grid;position:fixed;inset:0}.GjS9-q_dialog{background:var(--dsh-qr-share-dialog-bg,var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-overlay,#2c2c2e)));color:var(--dsh-qr-share-dialog-fg,currentColor);border:1px solid color-mix(in srgb, currentColor 12%, transparent);border-radius:12px;flex-direction:column;align-items:center;gap:12px;min-width:320px;max-width:420px;padding:24px;animation:.16s cubic-bezier(.2,.9,.3,1.2) GjS9-q_dsh-qr-share-pop-in;display:flex;box-shadow:0 12px 32px #00000040}.GjS9-q_title{margin:0;font-size:16px;font-weight:600}.GjS9-q_canvas{image-rendering:pixelated;background:#fff;border-radius:8px;padding:8px;display:block}.GjS9-q_err{color:#d23f3f;margin:0;font-size:13px}.GjS9-q_urlBlock{flex-direction:column;align-items:center;gap:4px;width:100%;display:flex}.GjS9-q_urlLabel{text-transform:uppercase;letter-spacing:.05em;opacity:.6;font-size:11px}.GjS9-q_url{word-break:break-all;background:color-mix(in srgb, currentColor 6%, transparent);text-align:center;border-radius:4px;max-width:100%;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.GjS9-q_hint{opacity:.7;text-align:center;margin:0;font-size:12px}.GjS9-
|
|
2695
|
+
const css = ".GjS9-q_button{width:100%;color:inherit;font:inherit;cursor:pointer;text-align:left;background:0 0;border:0;border-radius:6px;align-items:center;gap:8px;padding:8px 10px;display:inline-flex}.GjS9-q_button:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_button:focus-visible{outline-offset:-2px;outline:2px solid}.GjS9-q_buttonLabel{white-space:nowrap;text-overflow:ellipsis;font-size:13px;overflow:hidden}.GjS9-q_backdrop{z-index:9999;background:#00000073;place-items:center;animation:.12s ease-out GjS9-q_dsh-qr-share-fade-in;display:grid;position:fixed;inset:0}.GjS9-q_dialog{background:var(--dsh-qr-share-dialog-bg,var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-overlay,#2c2c2e)));color:var(--dsh-qr-share-dialog-fg,currentColor);border:1px solid color-mix(in srgb, currentColor 12%, transparent);border-radius:12px;flex-direction:column;align-items:center;gap:12px;min-width:320px;max-width:420px;padding:24px;animation:.16s cubic-bezier(.2,.9,.3,1.2) GjS9-q_dsh-qr-share-pop-in;display:flex;box-shadow:0 12px 32px #00000040}.GjS9-q_title{margin:0;font-size:16px;font-weight:600}.GjS9-q_canvas{image-rendering:pixelated;background:#fff;border-radius:8px;padding:8px;display:block}.GjS9-q_err{color:#d23f3f;margin:0;font-size:13px}.GjS9-q_urlBlock{flex-direction:column;align-items:center;gap:4px;width:100%;display:flex}.GjS9-q_urlLabel{text-transform:uppercase;letter-spacing:.05em;opacity:.6;font-size:11px}.GjS9-q_url{word-break:break-all;background:color-mix(in srgb, currentColor 6%, transparent);text-align:center;border-radius:4px;max-width:100%;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}.GjS9-q_hint{opacity:.7;text-align:center;margin:0;font-size:12px}.GjS9-q_actions{gap:8px;margin-top:4px;display:flex}.GjS9-q_action{border:1px solid color-mix(in srgb, currentColor 20%, transparent);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:6px;padding:8px 16px}.GjS9-q_action:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_action:focus-visible{outline-offset:-2px;outline:2px solid}@keyframes GjS9-q_dsh-qr-share-fade-in{0%{opacity:0}to{opacity:1}}@keyframes GjS9-q_dsh-qr-share-pop-in{0%{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}";
|
|
2607
2696
|
const tagId = "dsh-qr-share/styles.module.css";
|
|
2608
2697
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
2609
2698
|
const tag = document.createElement("style");
|
|
@@ -2613,20 +2702,21 @@ window.__ModuleLoader__.load({
|
|
|
2613
2702
|
document.head.appendChild(tag);
|
|
2614
2703
|
}
|
|
2615
2704
|
var styles_module_css_default = {
|
|
2616
|
-
"button": "GjS9-q_button",
|
|
2617
|
-
"canvas": "GjS9-q_canvas",
|
|
2618
|
-
"err": "GjS9-q_err",
|
|
2619
|
-
"dialog": "GjS9-q_dialog",
|
|
2620
|
-
"buttonLabel": "GjS9-q_buttonLabel",
|
|
2621
|
-
"dsh-qr-share-pop-in": "GjS9-q_dsh-qr-share-pop-in",
|
|
2622
|
-
"backdrop": "GjS9-q_backdrop",
|
|
2623
|
-
"urlLabel": "GjS9-q_urlLabel",
|
|
2624
|
-
"url": "GjS9-q_url",
|
|
2625
|
-
"hint": "GjS9-q_hint",
|
|
2626
2705
|
"title": "GjS9-q_title",
|
|
2706
|
+
"backdrop": "GjS9-q_backdrop",
|
|
2627
2707
|
"urlBlock": "GjS9-q_urlBlock",
|
|
2708
|
+
"dialog": "GjS9-q_dialog",
|
|
2709
|
+
"hint": "GjS9-q_hint",
|
|
2710
|
+
"err": "GjS9-q_err",
|
|
2711
|
+
"button": "GjS9-q_button",
|
|
2712
|
+
"urlLabel": "GjS9-q_urlLabel",
|
|
2713
|
+
"actions": "GjS9-q_actions",
|
|
2628
2714
|
"dsh-qr-share-fade-in": "GjS9-q_dsh-qr-share-fade-in",
|
|
2629
|
-
"
|
|
2715
|
+
"action": "GjS9-q_action",
|
|
2716
|
+
"dsh-qr-share-pop-in": "GjS9-q_dsh-qr-share-pop-in",
|
|
2717
|
+
"canvas": "GjS9-q_canvas",
|
|
2718
|
+
"buttonLabel": "GjS9-q_buttonLabel",
|
|
2719
|
+
"url": "GjS9-q_url"
|
|
2630
2720
|
};
|
|
2631
2721
|
//#endregion
|
|
2632
2722
|
//#region src/client/QrDialog.tsx
|
|
@@ -2654,6 +2744,30 @@ window.__ModuleLoader__.load({
|
|
|
2654
2744
|
url.searchParams.set("token", token);
|
|
2655
2745
|
return url.toString();
|
|
2656
2746
|
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Write text to the clipboard. Prefers the async Clipboard API; falls
|
|
2749
|
+
* back to the legacy execCommand path for older WebViews / non-secure
|
|
2750
|
+
* contexts where navigator.clipboard is absent.
|
|
2751
|
+
*/
|
|
2752
|
+
async function copyTextToClipboard(text) {
|
|
2753
|
+
if (typeof navigator !== "undefined" && navigator.clipboard !== void 0) try {
|
|
2754
|
+
await navigator.clipboard.writeText(text);
|
|
2755
|
+
return true;
|
|
2756
|
+
} catch {}
|
|
2757
|
+
try {
|
|
2758
|
+
const area = document.createElement("textarea");
|
|
2759
|
+
area.value = text;
|
|
2760
|
+
area.style.position = "fixed";
|
|
2761
|
+
area.style.opacity = "0";
|
|
2762
|
+
document.body.appendChild(area);
|
|
2763
|
+
area.select();
|
|
2764
|
+
const ok = document.execCommand("copy");
|
|
2765
|
+
document.body.removeChild(area);
|
|
2766
|
+
return ok;
|
|
2767
|
+
} catch {
|
|
2768
|
+
return false;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2657
2771
|
function QrDialog({ token, tokenTtlMs, t, onClose }) {
|
|
2658
2772
|
const canvasRef = (0, react.useRef)(null);
|
|
2659
2773
|
const [payload, setPayload] = (0, react.useState)("");
|
|
@@ -2680,6 +2794,21 @@ window.__ModuleLoader__.load({
|
|
|
2680
2794
|
setError(reason instanceof Error ? reason.message : String(reason));
|
|
2681
2795
|
});
|
|
2682
2796
|
}, [token, portalTarget]);
|
|
2797
|
+
const [copyState, setCopyState] = (0, react.useState)("idle");
|
|
2798
|
+
(0, react.useEffect)(() => {
|
|
2799
|
+
if (copyState === "idle") return;
|
|
2800
|
+
const timer = setTimeout(() => {
|
|
2801
|
+
setCopyState("idle");
|
|
2802
|
+
}, 1500);
|
|
2803
|
+
return () => {
|
|
2804
|
+
clearTimeout(timer);
|
|
2805
|
+
};
|
|
2806
|
+
}, [copyState]);
|
|
2807
|
+
const onCopy = () => {
|
|
2808
|
+
copyTextToClipboard(payload !== "" ? payload : composeQrUrl(token)).then((ok) => {
|
|
2809
|
+
setCopyState(ok ? "copied" : "failed");
|
|
2810
|
+
});
|
|
2811
|
+
};
|
|
2683
2812
|
(0, react.useEffect)(() => {
|
|
2684
2813
|
const onKey = (e) => {
|
|
2685
2814
|
if (e.key === "Escape") onClose();
|
|
@@ -2710,11 +2839,15 @@ window.__ModuleLoader__.load({
|
|
|
2710
2839
|
className: styles_module_css_default.canvas,
|
|
2711
2840
|
width: 256,
|
|
2712
2841
|
height: 256
|
|
2713
|
-
}), (0, react.createElement)("div", { className: styles_module_css_default.urlBlock }, (0, react.createElement)("span", { className: styles_module_css_default.urlLabel }, t("dialog.urlLabel")), (0, react.createElement)("code", { className: styles_module_css_default.url }, payload)), (0, react.createElement)("p", { className: styles_module_css_default.hint }, t("dialog.hint")), (0, react.createElement)("button", {
|
|
2842
|
+
}), (0, react.createElement)("div", { className: styles_module_css_default.urlBlock }, (0, react.createElement)("span", { className: styles_module_css_default.urlLabel }, t("dialog.urlLabel")), (0, react.createElement)("code", { className: styles_module_css_default.url }, payload)), (0, react.createElement)("p", { className: styles_module_css_default.hint }, t("dialog.hint")), (0, react.createElement)("div", { className: styles_module_css_default.actions }, (0, react.createElement)("button", {
|
|
2843
|
+
type: "button",
|
|
2844
|
+
className: styles_module_css_default.action,
|
|
2845
|
+
onClick: onCopy
|
|
2846
|
+
}, copyState === "copied" ? t("dialog.copied") : copyState === "failed" ? t("dialog.copyFailed") : t("dialog.copy")), (0, react.createElement)("button", {
|
|
2714
2847
|
type: "button",
|
|
2715
|
-
className: styles_module_css_default.
|
|
2848
|
+
className: styles_module_css_default.action,
|
|
2716
2849
|
onClick: onClose
|
|
2717
|
-
}, t("dialog.close")))), portalTarget);
|
|
2850
|
+
}, t("dialog.close"))))), portalTarget);
|
|
2718
2851
|
}
|
|
2719
2852
|
//#endregion
|
|
2720
2853
|
//#region src/client/QrButton.tsx
|
|
@@ -2823,10 +2956,15 @@ window.__ModuleLoader__.load({
|
|
|
2823
2956
|
* @param ctx - client root context (slots + locale + connection injected).
|
|
2824
2957
|
*/
|
|
2825
2958
|
function apply(ctx) {
|
|
2826
|
-
ctx.effect(() =>
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2959
|
+
ctx.effect(() => {
|
|
2960
|
+
const disposers = [ctx.locale.register(LOCALE_NS, {
|
|
2961
|
+
zh,
|
|
2962
|
+
en
|
|
2963
|
+
}), ...Object.entries(extraDicts).map(([locale, dict]) => ctx.locale.register(LOCALE_NS, locale, dict))];
|
|
2964
|
+
return () => {
|
|
2965
|
+
for (const dispose of disposers) dispose();
|
|
2966
|
+
};
|
|
2967
|
+
}, "qr-share: dictionaries");
|
|
2830
2968
|
const t = ctx.locale.bind(LOCALE_NS);
|
|
2831
2969
|
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
2832
2970
|
name: "sidebar.footer.action",
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-qr-share dictionaries
|
|
3
|
-
*
|
|
2
|
+
* dsh-qr-share dictionaries. The DSH shell ships `zh` + `en` as built-in
|
|
3
|
+
* locales and falls back through its language chain; additional
|
|
4
|
+
* dictionaries below (`zh-Hant`, `ja`, `ko`, `fr`, `de`, `ru`) register
|
|
5
|
+
* through the single-locale overload and activate whenever a language
|
|
6
|
+
* pack sets that preference. Unregistered locales still fall back to
|
|
7
|
+
* the built-in chain (`zh`/`en`).
|
|
4
8
|
*/
|
|
5
9
|
export declare const LOCALE_NS: "qr-share";
|
|
6
10
|
export interface QrShareKey {
|
|
@@ -9,8 +13,17 @@ export interface QrShareKey {
|
|
|
9
13
|
'dialog.title': string;
|
|
10
14
|
'dialog.hint': string;
|
|
11
15
|
'dialog.urlLabel': string;
|
|
16
|
+
'dialog.copy': string;
|
|
17
|
+
'dialog.copied': string;
|
|
18
|
+
'dialog.copyFailed': string;
|
|
12
19
|
'dialog.close': string;
|
|
13
20
|
'dialog.error': string;
|
|
14
21
|
}
|
|
15
22
|
export declare const zh: QrShareKey;
|
|
16
23
|
export declare const en: QrShareKey;
|
|
24
|
+
/**
|
|
25
|
+
* Extra dictionaries for non-built-in locales. These ride the
|
|
26
|
+
* single-locale registration overload; a matching locale only activates
|
|
27
|
+
* when a language-pack plugin sets the preference.
|
|
28
|
+
*/
|
|
29
|
+
export declare const extraDicts: Record<string, QrShareKey>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qr-share",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "DSH web plugin: a sidebar footer QR-code button that lets a phone scan and re-issue the current browser's authenticated launch URL, reusing the same ?token exchange the desktop just completed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -57,9 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"client": {
|
|
59
59
|
"inject": [
|
|
60
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
61
60
|
"@deepseek-ai/dsh-client-locale",
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-slots",
|
|
63
61
|
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
64
62
|
],
|
|
65
63
|
"platform": "web"
|
|
@@ -110,4 +110,28 @@ describe('QrDialog', () => {
|
|
|
110
110
|
fireEvent.click(getByRole('button', { name: 'dialog.close' }))
|
|
111
111
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
112
112
|
})
|
|
113
|
+
|
|
114
|
+
it('renders a Copy button that writes the composed URL via the Clipboard API', async () => {
|
|
115
|
+
const writeText = vi.fn(() => Promise.resolve())
|
|
116
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
117
|
+
value: { writeText },
|
|
118
|
+
configurable: true,
|
|
119
|
+
})
|
|
120
|
+
const { getByRole, findByText } = render(<QrDialog {...baseProps} />)
|
|
121
|
+
fireEvent.click(getByRole('button', { name: 'dialog.copy' }))
|
|
122
|
+
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/?token=tok-123`)
|
|
123
|
+
// Feedback flips to the localized "copied" label.
|
|
124
|
+
expect(await findByText('dialog.copied')).toBeInTheDocument()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('shows the failure label when the copy path rejects', async () => {
|
|
128
|
+
const writeText = vi.fn(() => Promise.reject(new Error('denied')))
|
|
129
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
130
|
+
value: { writeText },
|
|
131
|
+
configurable: true,
|
|
132
|
+
})
|
|
133
|
+
const { getByRole, findByText } = render(<QrDialog {...baseProps} />)
|
|
134
|
+
fireEvent.click(getByRole('button', { name: 'dialog.copy' }))
|
|
135
|
+
expect(await findByText('dialog.copyFailed')).toBeInTheDocument()
|
|
136
|
+
})
|
|
113
137
|
})
|
package/src/client/QrDialog.tsx
CHANGED
|
@@ -38,6 +38,35 @@ function composeQrUrl(token: string): string {
|
|
|
38
38
|
return url.toString()
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Write text to the clipboard. Prefers the async Clipboard API; falls
|
|
43
|
+
* back to the legacy execCommand path for older WebViews / non-secure
|
|
44
|
+
* contexts where navigator.clipboard is absent.
|
|
45
|
+
*/
|
|
46
|
+
async function copyTextToClipboard(text: string): Promise<boolean> {
|
|
47
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
|
|
48
|
+
try {
|
|
49
|
+
await navigator.clipboard.writeText(text)
|
|
50
|
+
return true
|
|
51
|
+
} catch {
|
|
52
|
+
// Fall through to the legacy path — e.g. permission policy denial.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const area = document.createElement('textarea')
|
|
57
|
+
area.value = text
|
|
58
|
+
area.style.position = 'fixed'
|
|
59
|
+
area.style.opacity = '0'
|
|
60
|
+
document.body.appendChild(area)
|
|
61
|
+
area.select()
|
|
62
|
+
const ok = document.execCommand('copy')
|
|
63
|
+
document.body.removeChild(area)
|
|
64
|
+
return ok
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
export function QrDialog({ token, tokenTtlMs, t, onClose }: QrDialogProps): ReactNode {
|
|
42
71
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
43
72
|
const [payload, setPayload] = useState<string>('')
|
|
@@ -79,6 +108,19 @@ export function QrDialog({ token, tokenTtlMs, t, onClose }: QrDialogProps): Reac
|
|
|
79
108
|
// dialog could render a "expires in Nh" line in the future.
|
|
80
109
|
void tokenTtlMs
|
|
81
110
|
|
|
111
|
+
// Copy feedback: idle → copied|copyFailed for a short window, then back.
|
|
112
|
+
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (copyState === 'idle') return
|
|
115
|
+
const timer = setTimeout(() => { setCopyState('idle') }, 1500)
|
|
116
|
+
return () => { clearTimeout(timer) }
|
|
117
|
+
}, [copyState])
|
|
118
|
+
|
|
119
|
+
const onCopy = (): void => {
|
|
120
|
+
const url = payload !== '' ? payload : composeQrUrl(token)
|
|
121
|
+
void copyTextToClipboard(url).then((ok) => { setCopyState(ok ? 'copied' : 'failed') })
|
|
122
|
+
}
|
|
123
|
+
|
|
82
124
|
// Escape + backdrop dismiss. The dialog is rendered above the sidebar
|
|
83
125
|
// (fixed position) so the user expects the same UX as other DSH modals.
|
|
84
126
|
useEffect(() => {
|
|
@@ -122,11 +164,22 @@ export function QrDialog({ token, tokenTtlMs, t, onClose }: QrDialogProps): Reac
|
|
|
122
164
|
createElement('code', { className: styles.url }, payload),
|
|
123
165
|
),
|
|
124
166
|
createElement('p', { className: styles.hint }, t('dialog.hint')),
|
|
125
|
-
createElement('
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
167
|
+
createElement('div', { className: styles.actions },
|
|
168
|
+
createElement('button', {
|
|
169
|
+
type: 'button',
|
|
170
|
+
className: styles.action,
|
|
171
|
+
onClick: onCopy,
|
|
172
|
+
}, copyState === 'copied'
|
|
173
|
+
? t('dialog.copied')
|
|
174
|
+
: copyState === 'failed'
|
|
175
|
+
? t('dialog.copyFailed')
|
|
176
|
+
: t('dialog.copy')),
|
|
177
|
+
createElement('button', {
|
|
178
|
+
type: 'button',
|
|
179
|
+
className: styles.action,
|
|
180
|
+
onClick: onClose,
|
|
181
|
+
}, t('dialog.close')),
|
|
182
|
+
),
|
|
130
183
|
),
|
|
131
184
|
),
|
|
132
185
|
portalTarget,
|
package/src/client/index.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import { Fragment, createElement, useEffect, useState } from 'react'
|
|
|
14
14
|
import type { ReactNode } from 'react'
|
|
15
15
|
import { QrButton } from './QrButton.tsx'
|
|
16
16
|
import { fetchShareInfo, type ShareInfo } from './api.ts'
|
|
17
|
-
import { LOCALE_NS, zh, en } from './locales.ts'
|
|
17
|
+
import { LOCALE_NS, zh, en, extraDicts } from './locales.ts'
|
|
18
18
|
|
|
19
19
|
// Ambient module augmentations — the host runtime supplies the real
|
|
20
20
|
// service shapes via type-only declarations in its `@deepseek-ai/*`
|
|
@@ -23,6 +23,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
23
23
|
interface Context {
|
|
24
24
|
locale: {
|
|
25
25
|
register(ns: string, dict: { zh: unknown; en: unknown }): () => void
|
|
26
|
+
register(ns: string, locale: string, dict: unknown): () => void
|
|
26
27
|
bind(ns: string): (k: string) => string
|
|
27
28
|
}
|
|
28
29
|
slots: {
|
|
@@ -89,7 +90,18 @@ function FooterAction(
|
|
|
89
90
|
export function apply(ctx: import('@deepseek-ai/cordis').Context): void {
|
|
90
91
|
// Locale registration is a side-effect on the locale service; the slot
|
|
91
92
|
// rendering reads through the bound `t` thunk the apply captures here.
|
|
92
|
-
|
|
93
|
+
// Built-in locales use the one-call form; extra dictionaries register
|
|
94
|
+
// individually via the single-locale overload. All disposers run in one
|
|
95
|
+
// effect so HMR / fiber teardown removes every dictionary together.
|
|
96
|
+
ctx.effect(() => {
|
|
97
|
+
const disposers = [
|
|
98
|
+
ctx.locale.register(LOCALE_NS, { zh, en }),
|
|
99
|
+
...Object.entries(extraDicts).map(([locale, dict]) =>
|
|
100
|
+
ctx.locale.register(LOCALE_NS, locale, dict),
|
|
101
|
+
),
|
|
102
|
+
]
|
|
103
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
104
|
+
}, 'qr-share: dictionaries')
|
|
93
105
|
const t = ctx.locale.bind(LOCALE_NS) as (k: keyof typeof zh) => string
|
|
94
106
|
|
|
95
107
|
ctx.slots.inject('sidebar.footer.action', () =>
|
package/src/client/locales.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-qr-share dictionaries
|
|
3
|
-
*
|
|
2
|
+
* dsh-qr-share dictionaries. The DSH shell ships `zh` + `en` as built-in
|
|
3
|
+
* locales and falls back through its language chain; additional
|
|
4
|
+
* dictionaries below (`zh-Hant`, `ja`, `ko`, `fr`, `de`, `ru`) register
|
|
5
|
+
* through the single-locale overload and activate whenever a language
|
|
6
|
+
* pack sets that preference. Unregistered locales still fall back to
|
|
7
|
+
* the built-in chain (`zh`/`en`).
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
export const LOCALE_NS = 'qr-share' as const
|
|
@@ -11,6 +15,9 @@ export interface QrShareKey {
|
|
|
11
15
|
'dialog.title': string
|
|
12
16
|
'dialog.hint': string
|
|
13
17
|
'dialog.urlLabel': string
|
|
18
|
+
'dialog.copy': string
|
|
19
|
+
'dialog.copied': string
|
|
20
|
+
'dialog.copyFailed': string
|
|
14
21
|
'dialog.close': string
|
|
15
22
|
'dialog.error': string
|
|
16
23
|
}
|
|
@@ -22,6 +29,9 @@ export const zh: QrShareKey = {
|
|
|
22
29
|
'dialog.hint':
|
|
23
30
|
'用手机相机扫描下方二维码。手机打开后会自动以当前账号登录,token 与浏览器 cookie 同寿命。',
|
|
24
31
|
'dialog.urlLabel': '链接',
|
|
32
|
+
'dialog.copy': '复制链接',
|
|
33
|
+
'dialog.copied': '已复制',
|
|
34
|
+
'dialog.copyFailed': '复制失败',
|
|
25
35
|
'dialog.close': '关闭',
|
|
26
36
|
'dialog.error': '二维码生成失败',
|
|
27
37
|
}
|
|
@@ -33,6 +43,95 @@ export const en: QrShareKey = {
|
|
|
33
43
|
'dialog.hint':
|
|
34
44
|
'Scan the QR code below with your phone camera. The phone will log in to this session; the token lives as long as the browser cookie.',
|
|
35
45
|
'dialog.urlLabel': 'URL',
|
|
46
|
+
'dialog.copy': 'Copy link',
|
|
47
|
+
'dialog.copied': 'Copied',
|
|
48
|
+
'dialog.copyFailed': 'Copy failed',
|
|
36
49
|
'dialog.close': 'Close',
|
|
37
50
|
'dialog.error': 'Failed to render QR code',
|
|
38
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extra dictionaries for non-built-in locales. These ride the
|
|
55
|
+
* single-locale registration overload; a matching locale only activates
|
|
56
|
+
* when a language-pack plugin sets the preference.
|
|
57
|
+
*/
|
|
58
|
+
export const extraDicts: Record<string, QrShareKey> = {
|
|
59
|
+
'zh-Hant': {
|
|
60
|
+
'button.label': '分享',
|
|
61
|
+
'button.tooltip': '掃碼將目前工作階段分享到手機',
|
|
62
|
+
'dialog.title': '掃碼分享',
|
|
63
|
+
'dialog.hint':
|
|
64
|
+
'用手機相機掃描下方 QR Code。手機開啟後會自動以目前帳號登入,token 與瀏覽器 cookie 同壽命。',
|
|
65
|
+
'dialog.urlLabel': '連結',
|
|
66
|
+
'dialog.copy': '複製連結',
|
|
67
|
+
'dialog.copied': '已複製',
|
|
68
|
+
'dialog.copyFailed': '複製失敗',
|
|
69
|
+
'dialog.close': '關閉',
|
|
70
|
+
'dialog.error': 'QR Code 產生失敗',
|
|
71
|
+
},
|
|
72
|
+
ja: {
|
|
73
|
+
'button.label': '共有',
|
|
74
|
+
'button.tooltip': 'スキャンしてこのセッションをスマホに共有',
|
|
75
|
+
'dialog.title': 'スキャンして共有',
|
|
76
|
+
'dialog.hint':
|
|
77
|
+
'スマホのカメラで下のQRコードを読み取ってください。そのセッションに自動でログインし、トークンの有効期限はブラウザのCookieと同じです。',
|
|
78
|
+
'dialog.urlLabel': 'リンク',
|
|
79
|
+
'dialog.copy': 'リンクをコピー',
|
|
80
|
+
'dialog.copied': 'コピーしました',
|
|
81
|
+
'dialog.copyFailed': 'コピーに失敗しました',
|
|
82
|
+
'dialog.close': '閉じる',
|
|
83
|
+
'dialog.error': 'QRコードの生成に失敗しました',
|
|
84
|
+
},
|
|
85
|
+
ko: {
|
|
86
|
+
'button.label': '공유',
|
|
87
|
+
'button.tooltip': '스캔하여 현재 세션을 휴대폰으로 공유',
|
|
88
|
+
'dialog.title': '스캔하여 공유',
|
|
89
|
+
'dialog.hint':
|
|
90
|
+
'휴대폰 카메라로 아래 QR 코드를 스캔하세요. 휴대폰이 이 세션에 자동으로 로그인되며, 토큰은 브라우저 쿠키와 같은 수명을 가집니다.',
|
|
91
|
+
'dialog.urlLabel': '링크',
|
|
92
|
+
'dialog.copy': '링크 복사',
|
|
93
|
+
'dialog.copied': '복사됨',
|
|
94
|
+
'dialog.copyFailed': '복사 실패',
|
|
95
|
+
'dialog.close': '닫기',
|
|
96
|
+
'dialog.error': 'QR 코드 생성 실패',
|
|
97
|
+
},
|
|
98
|
+
fr: {
|
|
99
|
+
'button.label': 'Partager',
|
|
100
|
+
'button.tooltip': 'Scannez pour partager cette session avec votre téléphone',
|
|
101
|
+
'dialog.title': 'Scanner pour partager',
|
|
102
|
+
'dialog.hint':
|
|
103
|
+
"Scannez le QR code ci-dessous avec l'appareil photo de votre téléphone. Le téléphone se connectera à cette session ; le jeton a la même durée de vie que le cookie du navigateur.",
|
|
104
|
+
'dialog.urlLabel': 'Lien',
|
|
105
|
+
'dialog.copy': 'Copier le lien',
|
|
106
|
+
'dialog.copied': 'Copié',
|
|
107
|
+
'dialog.copyFailed': 'Échec de la copie',
|
|
108
|
+
'dialog.close': 'Fermer',
|
|
109
|
+
'dialog.error': 'Échec de la génération du QR code',
|
|
110
|
+
},
|
|
111
|
+
de: {
|
|
112
|
+
'button.label': 'Teilen',
|
|
113
|
+
'button.tooltip': 'Scannen, um diese Sitzung mit dem Telefon zu teilen',
|
|
114
|
+
'dialog.title': 'Scannen zum Teilen',
|
|
115
|
+
'dialog.hint':
|
|
116
|
+
'Scannen Sie den untenstehenden QR-Code mit der Kamera Ihres Telefons. Das Telefon meldet sich an dieser Sitzung an; das Token lebt so lange wie das Browser-Cookie.',
|
|
117
|
+
'dialog.urlLabel': 'Link',
|
|
118
|
+
'dialog.copy': 'Link kopieren',
|
|
119
|
+
'dialog.copied': 'Kopiert',
|
|
120
|
+
'dialog.copyFailed': 'Kopieren fehlgeschlagen',
|
|
121
|
+
'dialog.close': 'Schließen',
|
|
122
|
+
'dialog.error': 'QR-Code konnte nicht erstellt werden',
|
|
123
|
+
},
|
|
124
|
+
ru: {
|
|
125
|
+
'button.label': 'Поделиться',
|
|
126
|
+
'button.tooltip': 'Отсканируйте, чтобы поделиться этой сессией с телефоном',
|
|
127
|
+
'dialog.title': 'Отсканируйте для доступа',
|
|
128
|
+
'dialog.hint':
|
|
129
|
+
'Отсканируйте QR-код ниже камерой телефона. Телефон войдёт в эту сессию; токен живёт столько же, сколько cookie браузера.',
|
|
130
|
+
'dialog.urlLabel': 'Ссылка',
|
|
131
|
+
'dialog.copy': 'Копировать ссылку',
|
|
132
|
+
'dialog.copied': 'Скопировано',
|
|
133
|
+
'dialog.copyFailed': 'Не удалось скопировать',
|
|
134
|
+
'dialog.close': 'Закрыть',
|
|
135
|
+
'dialog.error': 'Не удалось создать QR-код',
|
|
136
|
+
},
|
|
137
|
+
}
|
|
@@ -146,8 +146,13 @@
|
|
|
146
146
|
margin: 0;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
.
|
|
149
|
+
.actions {
|
|
150
|
+
display: flex;
|
|
151
|
+
gap: 8px;
|
|
150
152
|
margin-top: 4px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.action {
|
|
151
156
|
padding: 8px 16px;
|
|
152
157
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
153
158
|
background: transparent;
|
|
@@ -157,11 +162,11 @@
|
|
|
157
162
|
cursor: pointer;
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
.
|
|
165
|
+
.action:hover {
|
|
161
166
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
162
167
|
}
|
|
163
168
|
|
|
164
|
-
.
|
|
169
|
+
.action:focus-visible {
|
|
165
170
|
outline: 2px solid currentColor;
|
|
166
171
|
outline-offset: -2px;
|
|
167
172
|
}
|