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 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.1.0...HEAD
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
- # To disable the plugin without uninstalling, swap this row's `name`
18
- # to a `!!js` expression returning a disabled placeholder (see
19
- # dsh-better-sidebar's cordis.patch.yml for the pattern).
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'
@@ -2579,8 +2579,12 @@ window.__ModuleLoader__.load({
2579
2579
  });
2580
2580
  })))(), 1);
2581
2581
  /**
2582
- * dsh-qr-share dictionaries: zh-Hans + en. The shell's locale service
2583
- * drives the active language; the footer action reads via `ctx.locale.bind`.
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-q_close{border:1px solid color-mix(in srgb, currentColor 20%, transparent);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:6px;margin-top:4px;padding:8px 16px}.GjS9-q_close:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_close: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)}}";
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
- "urlLabel": "GjS9-q_urlLabel",
2621
- "url": "GjS9-q_url",
2711
+ "action": "GjS9-q_action",
2622
2712
  "hint": "GjS9-q_hint",
2623
- "backdrop": "GjS9-q_backdrop",
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
- "canvas": "GjS9-q_canvas"
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.close,
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(() => ctx.locale.register(LOCALE_NS, {
2827
- zh,
2828
- en
2829
- }), "qr-share: dictionaries");
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: zh-Hans + en. The shell's locale service
2583
- * drives the active language; the footer action reads via `ctx.locale.bind`.
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-q_close{border:1px solid color-mix(in srgb, currentColor 20%, transparent);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:6px;margin-top:4px;padding:8px 16px}.GjS9-q_close:hover{background:color-mix(in srgb, currentColor 8%, transparent)}.GjS9-q_close: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)}}";
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
- "close": "GjS9-q_close"
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.close,
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(() => ctx.locale.register(LOCALE_NS, {
2827
- zh,
2828
- en
2829
- }), "qr-share: dictionaries");
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",
@@ -6,6 +6,7 @@ declare module '@deepseek-ai/cordis' {
6
6
  zh: unknown;
7
7
  en: unknown;
8
8
  }): () => void;
9
+ register(ns: string, locale: string, dict: unknown): () => void;
9
10
  bind(ns: string): (k: string) => string;
10
11
  };
11
12
  slots: {
@@ -1,6 +1,10 @@
1
1
  /**
2
- * dsh-qr-share dictionaries: zh-Hans + en. The shell's locale service
3
- * drives the active language; the footer action reads via `ctx.locale.bind`.
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.2.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
  })
@@ -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('button', {
126
- type: 'button',
127
- className: styles.close,
128
- onClick: onClose,
129
- }, t('dialog.close')),
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,
@@ -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
- ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'qr-share: dictionaries')
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', () =>
@@ -1,6 +1,10 @@
1
1
  /**
2
- * dsh-qr-share dictionaries: zh-Hans + en. The shell's locale service
3
- * drives the active language; the footer action reads via `ctx.locale.bind`.
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
- .close {
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
- .close:hover {
165
+ .action:hover {
161
166
  background: color-mix(in srgb, currentColor 8%, transparent);
162
167
  }
163
168
 
164
- .close:focus-visible {
169
+ .action:focus-visible {
165
170
  outline: 2px solid currentColor;
166
171
  outline-offset: -2px;
167
172
  }