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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +109 -0
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/cordis.patch.yml +22 -0
- package/dsh.plugin.json +16 -0
- package/lib/client-registry.js +2804 -0
- package/lib/client.js +2804 -0
- package/lib/index.js +78 -0
- package/lib/invariant.js +24 -0
- package/lib/types/client/QrButton.d.ts +13 -0
- package/lib/types/client/QrDialog.d.ts +20 -0
- package/lib/types/client/api.d.ts +17 -0
- package/lib/types/client/index.d.ts +32 -0
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/config.d.ts +28 -0
- package/lib/types/context-types.d.ts +34 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/invariant.d.ts +9 -0
- package/package.json +135 -0
- package/src/client/QrButton.tsx +49 -0
- package/src/client/QrDialog.tsx +105 -0
- package/src/client/api.ts +41 -0
- package/src/client/index.tsx +107 -0
- package/src/client/locales.ts +38 -0
- package/src/client/styles.module.css +158 -0
- package/src/config.ts +37 -0
- package/src/context-types.ts +39 -0
- package/src/index.ts +107 -0
- package/src/invariant.ts +27 -0
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 };
|
package/lib/invariant.js
ADDED
|
@@ -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
|
+
}
|