dsh-qr-share 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,78 @@
1
+ import { assertNodeContext } from "./invariant.js";
2
+ //#region src/config.ts
3
+ const DEFAULTS = { enabled: true };
4
+ /**
5
+ * Apply defaults + permissive coercion. Patch-time `config:` maps are
6
+ * untyped JS objects; anything truthy that isn't explicitly `false`
7
+ * counts as enabled.
8
+ * @param raw - patch-time config object (or undefined).
9
+ * @returns resolved config.
10
+ */
11
+ function resolveConfig(raw) {
12
+ if (raw === null || typeof raw !== "object") return DEFAULTS;
13
+ if (raw.enabled === false) return { enabled: false };
14
+ return DEFAULTS;
15
+ }
16
+ //#endregion
17
+ //#region src/index.ts
18
+ const name = "dsh-qr-share";
19
+ /** Required services from the cordis fiber. */
20
+ const inject = ["connection", "webServer"];
21
+ /**
22
+ * The exact HTTP route name. Hard-coded so the plugin self-documents and
23
+ * a misconfigured patch never silently mounts under a wrong path.
24
+ */
25
+ const ROUTE_PATH = "/_qr/share";
26
+ /** Wall-clock approximate TTL of the browser cookie — purely advisory. */
27
+ const ADVISORY_TOKEN_TTL_MS = 864e5;
28
+ /** Resolve the launch token from the live BrowserAuth, given a request. */
29
+ function resolveToken(ctx, hostHeader) {
30
+ const baseUrl = `http://${hostHeader}`;
31
+ const launchUrl = ctx.connection.authenticatedUrl(baseUrl);
32
+ return new URL(launchUrl).searchParams.get("token") ?? "";
33
+ }
34
+ /**
35
+ * Mount the route and return the disposer. Plugin fiber disposal removes
36
+ * it via the webServer's own bookkeeping.
37
+ * @param ctx - host cordis context (connection + webServer injected).
38
+ * @param rawConfig - patch-time config (validated by resolveConfig).
39
+ */
40
+ function apply(ctx, rawConfig) {
41
+ assertNodeContext();
42
+ if (!resolveConfig(rawConfig).enabled) return;
43
+ const connection = ctx.connection;
44
+ ctx.webServer.register({
45
+ kind: "exact",
46
+ path: ROUTE_PATH,
47
+ handler: (req, res) => {
48
+ const rejection = connection.requestRejection(req);
49
+ if (rejection !== void 0) {
50
+ res.writeHead(rejection, {
51
+ "content-type": "text/plain; charset=utf-8",
52
+ "cache-control": "no-store"
53
+ });
54
+ res.end(rejection === 401 ? "qr-share: authentication required\n" : "qr-share: forbidden\n");
55
+ return;
56
+ }
57
+ const hostHeader = req.headers.host;
58
+ if (typeof hostHeader !== "string" || hostHeader === "") {
59
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
60
+ res.end("qr-share: missing Host header\n");
61
+ return;
62
+ }
63
+ const token = resolveToken(ctx, hostHeader);
64
+ const body = JSON.stringify({
65
+ ready: token !== "",
66
+ token,
67
+ tokenTtlMs: ADVISORY_TOKEN_TTL_MS
68
+ });
69
+ res.writeHead(200, {
70
+ "content-type": "application/json; charset=utf-8",
71
+ "cache-control": "no-store"
72
+ });
73
+ res.end(body);
74
+ }
75
+ });
76
+ }
77
+ //#endregion
78
+ export { apply, inject, name, resolveConfig };
@@ -0,0 +1,24 @@
1
+ import { createRequire } from "node:module";
2
+ //#region src/invariant.ts
3
+ /**
4
+ * dsh-qr-share invariant — fails fast when the host half is loaded without
5
+ * the services it declares in `inject`. Mirrors the official DSH plugin
6
+ * invariant pattern (used by every bundle in the monorepo) so a misconfigured
7
+ * mount surfaces a precise error rather than a runtime undefined access.
8
+ */
9
+ const require = createRequire(import.meta.url);
10
+ /** Throws when the import-time guard fails; never returns. */
11
+ function assert(condition, message) {
12
+ if (!condition) throw new Error(`dsh-qr-share: ${message}`);
13
+ }
14
+ /**
15
+ * Guard `require` to the official Node context — the host half MUST NOT
16
+ * be loaded through the browser module table. The bundler's purity gate
17
+ * already blocks Node-builtin imports in the client bundles; this is the
18
+ * defense-in-depth runtime check.
19
+ */
20
+ function assertNodeContext() {
21
+ require("node:fs");
22
+ }
23
+ //#endregion
24
+ export { assert, assertNodeContext };
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface QrButtonProps {
3
+ /** Authoritative payload from the host half. */
4
+ info: {
5
+ token: string;
6
+ tokenTtlMs: number;
7
+ };
8
+ /** Sidebar column state: true = wide (icon + label), false = rail (icon only). */
9
+ wide: boolean;
10
+ /** Locale reader from ctx.locale.bind(LOCALE_NS). */
11
+ t: (key: keyof typeof import('./locales.ts').zh) => string;
12
+ }
13
+ export declare function QrButton({ info, wide, t }: QrButtonProps): ReactNode;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * QR-code modal: a fixed-position overlay with a canvas, the resolved URL
3
+ * as plain text (so the user can type it manually if scanning fails), and
4
+ * a Close button. The URL is composed from `window.location.origin` —
5
+ * not from the host — so it always matches whatever authority the
6
+ * desktop just used to reach this page.
7
+ *
8
+ * Server-side constraint: BrowserAuth.authorizeIndex only accepts token
9
+ * exchanges on path `/` (see browser-auth.ts). We enforce that here so a
10
+ * future reverse-proxy sub-path does NOT silently produce a 401 on the
11
+ * phone.
12
+ */
13
+ import { type ReactNode } from 'react';
14
+ export interface QrDialogProps {
15
+ token: string;
16
+ tokenTtlMs: number;
17
+ t: (key: keyof typeof import('./locales.ts').zh) => string;
18
+ onClose: () => void;
19
+ }
20
+ export declare function QrDialog({ token, tokenTtlMs, t, onClose }: QrDialogProps): ReactNode;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Client-side wrapper around the host half's `/_qr/share` route. A single
3
+ * GET; the browser's cookie is carried automatically (SameSite=Strict,
4
+ * set by BrowserAuth at the same authority).
5
+ *
6
+ * Returns null on any non-2xx or network failure so the caller can treat
7
+ * "not ready" and "still probing" identically.
8
+ */
9
+ export interface ShareInfo {
10
+ /** True when the server saw an authenticated browser AND produced a token. */
11
+ ready: boolean;
12
+ /** Fresh launch token (process-stable; cookie-bound by Host once consumed). */
13
+ token: string;
14
+ /** Advisory TTL displayed in the dialog — not enforced server-side. */
15
+ tokenTtlMs: number;
16
+ }
17
+ export declare function fetchShareInfo(signal?: AbortSignal): Promise<ShareInfo | null>;
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from 'react';
2
+ declare module '@deepseek-ai/cordis' {
3
+ interface Context {
4
+ locale: {
5
+ register(ns: string, dict: {
6
+ zh: unknown;
7
+ en: unknown;
8
+ }): () => void;
9
+ bind(ns: string): (k: string) => string;
10
+ };
11
+ slots: {
12
+ inject(slot: string, factory: () => unknown): void;
13
+ register(opts: {
14
+ name: string;
15
+ id: string;
16
+ order: number;
17
+ locale: string;
18
+ }, component: (owner: SidebarFooterActionOwnerPropsLike) => ReactNode): () => void;
19
+ };
20
+ }
21
+ }
22
+ interface SidebarFooterActionOwnerPropsLike {
23
+ wide: boolean;
24
+ }
25
+ /** Services required before this entry activates. */
26
+ export declare const inject: readonly ["slots", "locale", "connection"];
27
+ /**
28
+ * Register dictionaries and the slot occupant.
29
+ * @param ctx - client root context (slots + locale + connection injected).
30
+ */
31
+ export declare function apply(ctx: import('@deepseek-ai/cordis').Context): void;
32
+ export {};
@@ -0,0 +1,16 @@
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`.
4
+ */
5
+ export declare const LOCALE_NS: "qr-share";
6
+ export interface QrShareKey {
7
+ 'button.label': string;
8
+ 'button.tooltip': string;
9
+ 'dialog.title': string;
10
+ 'dialog.hint': string;
11
+ 'dialog.urlLabel': string;
12
+ 'dialog.close': string;
13
+ 'dialog.error': string;
14
+ }
15
+ export declare const zh: QrShareKey;
16
+ export declare const en: QrShareKey;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * dsh-qr-share Config schema. The host half is mounted as a cordis
3
+ * `insert` row; the `config` field in the patch is fed here. Defaults
4
+ * match a "just works" deployment with no patch config.
5
+ *
6
+ * Note: we DO NOT read DSH_PUBLIC_HOST here — the QR URL is composed
7
+ * by the browser from `window.location.origin`, so no configuration is
8
+ * required for the QR to point at the right host. The web-app
9
+ * trustedHosts patch (set elsewhere) still gates the /_qr/share route
10
+ * via the same trust fence that gates /api.
11
+ *
12
+ * We intentionally avoid @deepseek-ai/schemastery / zod — the patch
13
+ * overlay can only feed plain JS values, so a hand-rolled validator
14
+ * keeps the host bundle dependency-free.
15
+ */
16
+ /** Wire shape (defaults applied). */
17
+ export interface ResolvedConfig {
18
+ /** Master switch: false short-circuits the route to 404. */
19
+ enabled: boolean;
20
+ }
21
+ /**
22
+ * Apply defaults + permissive coercion. Patch-time `config:` maps are
23
+ * untyped JS objects; anything truthy that isn't explicitly `false`
24
+ * counts as enabled.
25
+ * @param raw - patch-time config object (or undefined).
26
+ * @returns resolved config.
27
+ */
28
+ export declare function resolveConfig(raw: unknown): ResolvedConfig;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * dsh-qr-share host context types. Declares the cordis Context services
3
+ * the host half consumes as ambient module augmentations. The actual
4
+ * services come from the loaded Connection + WebServer plugins; we
5
+ * declare the slices we use and rely on the module loader to supply
6
+ * the real implementations.
7
+ *
8
+ * Runtime: zero import from monorepo; type-only augmentations are
9
+ * stripped by tsdown.
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from 'node:http';
12
+ /** Minimal slice of `connection.requestRejection` we consume. */
13
+ export interface ConnectionRejectionLike {
14
+ /** 401 when cookie missing/expired; 403 when Host/Origin not trusted. */
15
+ requestRejection(req: IncomingMessage): number | undefined;
16
+ /** Add the fresh process token to a clean URL. */
17
+ authenticatedUrl(baseUrl: string): string;
18
+ }
19
+ /** Minimal slice of `webServer.register` we consume. */
20
+ export interface WebServerLike {
21
+ register(route: {
22
+ kind: 'exact';
23
+ path: string;
24
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
25
+ }): () => void;
26
+ }
27
+ declare module '@deepseek-ai/cordis' {
28
+ interface Context {
29
+ connection: ConnectionRejectionLike;
30
+ webServer: WebServerLike;
31
+ }
32
+ }
33
+ /** Re-export for ergonomic imports. */
34
+ export type Context = import('@deepseek-ai/cordis').Context;
@@ -0,0 +1,14 @@
1
+ import { resolveConfig } from './config.ts';
2
+ import type { Context } from './context-types.ts';
3
+ export declare const name = "dsh-qr-share";
4
+ export { resolveConfig };
5
+ export type { ResolvedConfig } from './config.ts';
6
+ /** Required services from the cordis fiber. */
7
+ export declare const inject: readonly ["connection", "webServer"];
8
+ /**
9
+ * Mount the route and return the disposer. Plugin fiber disposal removes
10
+ * it via the webServer's own bookkeeping.
11
+ * @param ctx - host cordis context (connection + webServer injected).
12
+ * @param rawConfig - patch-time config (validated by resolveConfig).
13
+ */
14
+ export declare function apply(ctx: Context, rawConfig: unknown): void;
@@ -0,0 +1,9 @@
1
+ /** Throws when the import-time guard fails; never returns. */
2
+ export declare function assert(condition: unknown, message: string): asserts condition;
3
+ /**
4
+ * Guard `require` to the official Node context — the host half MUST NOT
5
+ * be loaded through the browser module table. The bundler's purity gate
6
+ * already blocks Node-builtin imports in the client bundles; this is the
7
+ * defense-in-depth runtime check.
8
+ */
9
+ export declare function assertNodeContext(): void;
package/package.json ADDED
@@ -0,0 +1,135 @@
1
+ {
2
+ "name": "dsh-qr-share",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/xiaoguomeiyitian/dsh-qr-share.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/xiaoguomeiyitian/dsh-qr-share/issues"
12
+ },
13
+ "homepage": "https://github.com/xiaoguomeiyitian/dsh-qr-share#readme",
14
+ "keywords": [
15
+ "dsh",
16
+ "dsh-plugin",
17
+ "deepseek-harness",
18
+ "qr-code",
19
+ "web-plugin"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/"
24
+ },
25
+ "main": "lib/index.js",
26
+ "types": "lib/types/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./lib/types/index.d.ts",
30
+ "default": "./lib/index.js"
31
+ },
32
+ "./invariant": {
33
+ "types": "./lib/types/invariant.d.ts",
34
+ "default": "./lib/invariant.js"
35
+ },
36
+ "./client": {
37
+ "types": "./lib/types/client/index.d.ts",
38
+ "default": "./lib/client.js"
39
+ },
40
+ "./client/service": {
41
+ "types": "./lib/types/client/index.d.ts",
42
+ "default": "./lib/client.js"
43
+ },
44
+ "./client/api": {
45
+ "types": "./lib/types/client/index.d.ts",
46
+ "default": "./lib/client.js"
47
+ },
48
+ "./src/*": "./src/*",
49
+ "./package.json": "./package.json"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ },
58
+ "client": {
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-runtime",
61
+ "@deepseek-ai/dsh-client-locale",
62
+ "@deepseek-ai/dsh-client-ui-slots",
63
+ "@deepseek-ai/dsh-client-ui-sidebar"
64
+ ],
65
+ "platform": "web"
66
+ }
67
+ },
68
+ "files": [
69
+ "lib/index.js",
70
+ "lib/invariant.js",
71
+ "lib/client.js",
72
+ "lib/client-registry.js",
73
+ "lib/types/**/*.d.ts",
74
+ "src",
75
+ "cordis.patch.yml",
76
+ "dsh.plugin.json",
77
+ "README.md",
78
+ "README.zh.md",
79
+ "CHANGELOG.md",
80
+ "THIRD_PARTY_NOTICES.md",
81
+ "LICENSE"
82
+ ],
83
+ "scripts": {
84
+ "build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
85
+ "typecheck": "tsc --noEmit",
86
+ "bundle": "tsdown",
87
+ "watch": "tsdown --watch",
88
+ "test": "vitest run",
89
+ "pack:tarball": "pnpm build && pnpm pack"
90
+ },
91
+ "license": "MIT",
92
+ "author": {
93
+ "name": "xiaoguomeiyitian",
94
+ "url": "https://github.com/xiaoguomeiyitian"
95
+ },
96
+ "contributors": [
97
+ {
98
+ "name": "xiaoguomeiyitian",
99
+ "url": "https://github.com/xiaoguomeiyitian"
100
+ }
101
+ ],
102
+ "funding": {
103
+ "type": "github",
104
+ "url": "https://github.com/sponsors/xiaoguomeiyitian"
105
+ },
106
+ "peerDependencies": {
107
+ "react": "^18.0.0 || ^19.0.0",
108
+ "react-dom": "^18.0.0 || ^19.0.0"
109
+ },
110
+ "peerDependenciesMeta": {
111
+ "react": {
112
+ "optional": true
113
+ },
114
+ "react-dom": {
115
+ "optional": true
116
+ }
117
+ },
118
+ "dependencies": {
119
+ "qrcode": "^1.5.4"
120
+ },
121
+ "devDependencies": {
122
+ "@deepseek-ai/cordis": "^4.0.1",
123
+ "@types/node": "^22.0.0",
124
+ "@types/qrcode": "^1.5.5",
125
+ "@types/react": "^18.3.0",
126
+ "@types/react-dom": "^18.3.0",
127
+ "lightningcss": "^1.27.0",
128
+ "react": "^18.3.0",
129
+ "react-dom": "^18.3.0",
130
+ "react-icons": "^5.4.0",
131
+ "tsdown": "^0.22.14",
132
+ "typescript": "^5.6.0",
133
+ "vitest": "^2.0.0"
134
+ }
135
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Sidebar footer action: a QR-code icon button. Clicking opens the
3
+ * `QrDialog`. The button renders only when the server reports `ready`
4
+ * (i.e. the browser is past the launch-token exchange AND a token is
5
+ * available); otherwise it stays mounted-but-empty so the slot ledger
6
+ * remains stable across the login → main transition.
7
+ */
8
+ import { Fragment, createElement, useState } from 'react'
9
+ import type { ReactNode } from 'react'
10
+ import { HiOutlineQrCode } from 'react-icons/hi2'
11
+ import { QrDialog } from './QrDialog.tsx'
12
+ import { LOCALE_NS } from './locales.ts'
13
+
14
+ export interface QrButtonProps {
15
+ /** Authoritative payload from the host half. */
16
+ info: { token: string; tokenTtlMs: number }
17
+ /** Sidebar column state: true = wide (icon + label), false = rail (icon only). */
18
+ wide: boolean
19
+ /** Locale reader from ctx.locale.bind(LOCALE_NS). */
20
+ t: (key: keyof typeof import('./locales.ts').zh) => string
21
+ }
22
+
23
+ export function QrButton({ info, wide, t }: QrButtonProps): ReactNode {
24
+ const [open, setOpen] = useState(false)
25
+ return createElement(Fragment, null,
26
+ createElement('button', {
27
+ type: 'button',
28
+ className: 'dsh-qr-share-button',
29
+ 'aria-label': t('button.label'),
30
+ title: t('button.tooltip'),
31
+ onClick: () => { setOpen(true) },
32
+ },
33
+ createElement(HiOutlineQrCode, { size: wide ? 18 : 20 }),
34
+ wide
35
+ ? createElement('span', { className: 'dsh-qr-share-button-label' }, t('button.label'))
36
+ : null,
37
+ ),
38
+ open
39
+ ? createElement(QrDialog, {
40
+ token: info.token,
41
+ tokenTtlMs: info.tokenTtlMs,
42
+ t,
43
+ onClose: () => { setOpen(false) },
44
+ })
45
+ : null,
46
+ )
47
+ }
48
+
49
+ void LOCALE_NS
@@ -0,0 +1,105 @@
1
+ /**
2
+ * QR-code modal: a fixed-position overlay with a canvas, the resolved URL
3
+ * as plain text (so the user can type it manually if scanning fails), and
4
+ * a Close button. The URL is composed from `window.location.origin` —
5
+ * not from the host — so it always matches whatever authority the
6
+ * desktop just used to reach this page.
7
+ *
8
+ * Server-side constraint: BrowserAuth.authorizeIndex only accepts token
9
+ * exchanges on path `/` (see browser-auth.ts). We enforce that here so a
10
+ * future reverse-proxy sub-path does NOT silently produce a 401 on the
11
+ * phone.
12
+ */
13
+ import { createElement, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
14
+ import QRCode from 'qrcode'
15
+ import { LOCALE_NS } from './locales.ts'
16
+
17
+ export interface QrDialogProps {
18
+ token: string
19
+ tokenTtlMs: number
20
+ t: (key: keyof typeof import('./locales.ts').zh) => string
21
+ onClose: () => void
22
+ }
23
+
24
+ function composeQrUrl(token: string): string {
25
+ // URL API handles trailing slashes / port-less origins cleanly; the
26
+ // resulting href is always of the form `${origin}/?token=...`.
27
+ const url = new URL('/', window.location.origin)
28
+ url.searchParams.set('token', token)
29
+ return url.toString()
30
+ }
31
+
32
+ export function QrDialog({ token, tokenTtlMs, t, onClose }: QrDialogProps): ReactNode {
33
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
34
+ const [payload, setPayload] = useState<string>('')
35
+ const [error, setError] = useState<string | null>(null)
36
+
37
+ useEffect(() => {
38
+ if (token === '') return
39
+ const url = composeQrUrl(token)
40
+ setPayload(url)
41
+ const canvas = canvasRef.current
42
+ if (canvas !== null) {
43
+ void QRCode.toCanvas(canvas, url, {
44
+ errorCorrectionLevel: 'M',
45
+ margin: 1,
46
+ width: 256,
47
+ color: { dark: '#000000', light: '#ffffff' },
48
+ }).catch((reason: unknown) => {
49
+ setError(reason instanceof Error ? reason.message : String(reason))
50
+ })
51
+ }
52
+ }, [token])
53
+
54
+ // Silence unused-var: tokenTtlMs is part of the public surface; the
55
+ // dialog could render a "expires in Nh" line in the future.
56
+ void tokenTtlMs
57
+
58
+ // Escape + backdrop dismiss. The dialog is rendered above the sidebar
59
+ // (fixed position) so the user expects the same UX as other DSH modals.
60
+ useEffect(() => {
61
+ const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() }
62
+ window.addEventListener('keydown', onKey)
63
+ return () => { window.removeEventListener('keydown', onKey) }
64
+ }, [onClose])
65
+
66
+ const onBackdropClick = (e: MouseEvent<HTMLDivElement>): void => {
67
+ if (e.target === e.currentTarget) onClose()
68
+ }
69
+
70
+ return createElement('div', {
71
+ className: 'dsh-qr-share-backdrop',
72
+ role: 'presentation',
73
+ onClick: onBackdropClick,
74
+ },
75
+ createElement('div', {
76
+ className: 'dsh-qr-share-dialog',
77
+ role: 'dialog',
78
+ 'aria-modal': 'true',
79
+ 'aria-labelledby': 'dsh-qr-share-title',
80
+ },
81
+ createElement('h2', { id: 'dsh-qr-share-title', className: 'dsh-qr-share-title' },
82
+ t('dialog.title')),
83
+ error !== null
84
+ ? createElement('p', { className: 'dsh-qr-share-err' }, `${t('dialog.error')}: ${error}`)
85
+ : createElement('canvas', {
86
+ ref: canvasRef,
87
+ className: 'dsh-qr-share-canvas',
88
+ width: 256,
89
+ height: 256,
90
+ }),
91
+ createElement('div', { className: 'dsh-qr-share-url-block' },
92
+ createElement('span', { className: 'dsh-qr-share-url-label' }, t('dialog.urlLabel')),
93
+ createElement('code', { className: 'dsh-qr-share-url' }, payload),
94
+ ),
95
+ createElement('p', { className: 'dsh-qr-share-hint' }, t('dialog.hint')),
96
+ createElement('button', {
97
+ type: 'button',
98
+ className: 'dsh-qr-share-close',
99
+ onClick: onClose,
100
+ }, t('dialog.close')),
101
+ ),
102
+ )
103
+ }
104
+
105
+ void LOCALE_NS
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Client-side wrapper around the host half's `/_qr/share` route. A single
3
+ * GET; the browser's cookie is carried automatically (SameSite=Strict,
4
+ * set by BrowserAuth at the same authority).
5
+ *
6
+ * Returns null on any non-2xx or network failure so the caller can treat
7
+ * "not ready" and "still probing" identically.
8
+ */
9
+
10
+ export interface ShareInfo {
11
+ /** True when the server saw an authenticated browser AND produced a token. */
12
+ ready: boolean
13
+ /** Fresh launch token (process-stable; cookie-bound by Host once consumed). */
14
+ token: string
15
+ /** Advisory TTL displayed in the dialog — not enforced server-side. */
16
+ tokenTtlMs: number
17
+ }
18
+
19
+ const ROUTE_PATH = '/_qr/share'
20
+
21
+ export async function fetchShareInfo(signal?: AbortSignal): Promise<ShareInfo | null> {
22
+ try {
23
+ const res = await fetch(ROUTE_PATH, {
24
+ method: 'GET',
25
+ credentials: 'same-origin',
26
+ cache: 'no-store',
27
+ headers: { 'accept': 'application/json' },
28
+ ...(signal === undefined ? {} : { signal }),
29
+ })
30
+ if (!res.ok) return null
31
+ const data = await res.json() as Partial<ShareInfo>
32
+ if (typeof data.token !== 'string' || typeof data.ready !== 'boolean') return null
33
+ return {
34
+ ready: data.ready,
35
+ token: data.token,
36
+ tokenTtlMs: typeof data.tokenTtlMs === 'number' ? data.tokenTtlMs : 0,
37
+ }
38
+ } catch {
39
+ return null
40
+ }
41
+ }