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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * dsh-qr-share client half.
3
+ *
4
+ * Registers a single `sidebar.footer.action` slot occupant that renders
5
+ * the QR-code button. The button stays mounted but hidden until the host
6
+ * half's `/_qr/share` returns `ready: true` — that happens exactly when
7
+ * (a) the request passed `connection.requestRejection` (Host/Origin
8
+ * trusted AND browser cookie valid), AND
9
+ * (b) a non-empty launch token was minted.
10
+ * Both conditions jointly mean "the desktop has already completed the
11
+ * token-exchange redirect and now lives on /, not on /?token=…".
12
+ */
13
+ import { Fragment, createElement, useEffect, useState } from 'react'
14
+ import type { ReactNode } from 'react'
15
+ import { QrButton } from './QrButton.tsx'
16
+ import { fetchShareInfo, type ShareInfo } from './api.ts'
17
+ import { LOCALE_NS, zh, en } from './locales.ts'
18
+
19
+ // Ambient module augmentations — the host runtime supplies the real
20
+ // service shapes via type-only declarations in its `@deepseek-ai/*`
21
+ // packages. We declare just enough for our apply() to type-check.
22
+ declare module '@deepseek-ai/cordis' {
23
+ interface Context {
24
+ locale: {
25
+ register(ns: string, dict: { zh: unknown; en: unknown }): () => void
26
+ bind(ns: string): (k: string) => string
27
+ }
28
+ slots: {
29
+ inject(slot: string, factory: () => unknown): void
30
+ register(opts: {
31
+ name: string
32
+ id: string
33
+ order: number
34
+ locale: string
35
+ }, component: (owner: SidebarFooterActionOwnerPropsLike) => ReactNode): () => void
36
+ }
37
+ /** Empty marker: this entry only needs slots + locale. */
38
+ }
39
+ }
40
+
41
+ interface SidebarFooterActionOwnerPropsLike {
42
+ wide: boolean
43
+ }
44
+
45
+ /** Services required before this entry activates. */
46
+ export const inject = ['slots', 'locale', 'connection'] as const
47
+
48
+ /**
49
+ * The footer-action component. Mounted by the slot registry; receives the
50
+ * sidebar's wide/rail state via the slot owner props.
51
+ * @param owner - slot owner props from the sidebar shell.
52
+ * @param t - locale reader bound by the apply function below.
53
+ */
54
+ function FooterAction(
55
+ owner: SidebarFooterActionOwnerPropsLike,
56
+ t: (k: keyof typeof zh) => string,
57
+ ): ReactNode {
58
+ const [info, setInfo] = useState<ShareInfo | null>(null)
59
+
60
+ useEffect(() => {
61
+ // Probe on mount; refresh on window focus (a fresh login in another
62
+ // tab + focus-back should immediately enable the button).
63
+ let cancelled = false
64
+ const refresh = (): void => {
65
+ void fetchShareInfo().then((next) => {
66
+ if (!cancelled) setInfo(next)
67
+ })
68
+ }
69
+ refresh()
70
+ window.addEventListener('focus', refresh)
71
+ return () => {
72
+ cancelled = true
73
+ window.removeEventListener('focus', refresh)
74
+ }
75
+ }, [])
76
+
77
+ // Two-stage gate: null = still probing (no flicker); !ready = server
78
+ // says "no" (button hidden); ready = render.
79
+ if (info === null) return null
80
+ if (!info.ready) return null
81
+
82
+ return createElement(QrButton, { info, wide: owner.wide, t })
83
+ }
84
+
85
+ /**
86
+ * Register dictionaries and the slot occupant.
87
+ * @param ctx - client root context (slots + locale + connection injected).
88
+ */
89
+ export function apply(ctx: import('@deepseek-ai/cordis').Context): void {
90
+ // Locale registration is a side-effect on the locale service; the slot
91
+ // 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
+ const t = ctx.locale.bind(LOCALE_NS) as (k: keyof typeof zh) => string
94
+
95
+ ctx.slots.inject('sidebar.footer.action', () =>
96
+ ctx.slots.register({
97
+ name: 'sidebar.footer.action',
98
+ id: 'qr-share',
99
+ order: 100, // after the Settings icon (which is order 0/10)
100
+ locale: LOCALE_NS,
101
+ }, (owner: SidebarFooterActionOwnerPropsLike) => FooterAction(owner, t)),
102
+ )
103
+ }
104
+
105
+ // Suppress unused warning for Fragment; we keep the import so future
106
+ // expansions (toast messages, multi-button groups) don't churn imports.
107
+ void Fragment
@@ -0,0 +1,38 @@
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
+
6
+ export const LOCALE_NS = 'qr-share' as const
7
+
8
+ export interface QrShareKey {
9
+ 'button.label': string
10
+ 'button.tooltip': string
11
+ 'dialog.title': string
12
+ 'dialog.hint': string
13
+ 'dialog.urlLabel': string
14
+ 'dialog.close': string
15
+ 'dialog.error': string
16
+ }
17
+
18
+ export const zh: QrShareKey = {
19
+ 'button.label': '扫码登录',
20
+ 'button.tooltip': '用手机扫码以同一账号登录',
21
+ 'dialog.title': '用手机扫码登录',
22
+ 'dialog.hint':
23
+ '用手机相机扫描下方二维码。手机打开后会自动以当前账号登录,token 与浏览器 cookie 同寿命。',
24
+ 'dialog.urlLabel': '链接',
25
+ 'dialog.close': '关闭',
26
+ 'dialog.error': '二维码生成失败',
27
+ }
28
+
29
+ export const en: QrShareKey = {
30
+ 'button.label': 'Mobile Login',
31
+ 'button.tooltip': 'Scan with a phone to log in to this session',
32
+ 'dialog.title': 'Scan to log in on mobile',
33
+ 'dialog.hint':
34
+ '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
+ 'dialog.urlLabel': 'URL',
36
+ 'dialog.close': 'Close',
37
+ 'dialog.error': 'Failed to render QR code',
38
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * dsh-qr-share styles. Two pieces:
3
+ *
4
+ * 1. The sidebar footer action button — same row layout as the official
5
+ * Settings trigger (`sidebar.settings` occupant). The shell handles
6
+ * wide vs rail sizing; we only style the inner button + icon.
7
+ *
8
+ * 2. The dialog — a centered overlay with backdrop, mimicking the visual
9
+ * tone of the existing Settings panel without depending on its CSS
10
+ * variables (plugins can't reach the shell's CSS module surface).
11
+ *
12
+ * Colors are kept neutral so the plugin inherits whichever theme the
13
+ * user has active (light/dark) via `color-scheme` + native inheritance.
14
+ */
15
+
16
+ .button {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ gap: 8px;
20
+ width: 100%;
21
+ padding: 8px 10px;
22
+ border: 0;
23
+ border-radius: 6px;
24
+ background: transparent;
25
+ color: inherit;
26
+ font: inherit;
27
+ cursor: pointer;
28
+ text-align: left;
29
+ }
30
+
31
+ .button:hover {
32
+ background: color-mix(in srgb, currentColor 8%, transparent);
33
+ }
34
+
35
+ .button:focus-visible {
36
+ outline: 2px solid currentColor;
37
+ outline-offset: -2px;
38
+ }
39
+
40
+ .buttonLabel {
41
+ font-size: 13px;
42
+ white-space: nowrap;
43
+ overflow: hidden;
44
+ text-overflow: ellipsis;
45
+ }
46
+
47
+ .backdrop {
48
+ position: fixed;
49
+ inset: 0;
50
+ background: color-mix(in srgb, black 45%, transparent);
51
+ display: grid;
52
+ place-items: center;
53
+ z-index: 9999;
54
+ animation: dsh-qr-share-fade-in 120ms ease-out;
55
+ }
56
+
57
+ .dialog {
58
+ background: var(--dsh-qr-share-dialog-bg, #ffffff);
59
+ color: var(--dsh-qr-share-dialog-fg, #111111);
60
+ border-radius: 12px;
61
+ padding: 24px;
62
+ min-width: 320px;
63
+ max-width: 420px;
64
+ box-shadow: 0 12px 32px color-mix(in srgb, black 25%, transparent);
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 12px;
68
+ align-items: center;
69
+ animation: dsh-qr-share-pop-in 160ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
70
+ }
71
+
72
+ @media (prefers-color-scheme: dark) {
73
+ .dialog {
74
+ background: var(--dsh-qr-share-dialog-bg, #1c1c1f);
75
+ color: var(--dsh-qr-share-dialog-fg, #f0f0f0);
76
+ }
77
+ }
78
+
79
+ .title {
80
+ font-size: 16px;
81
+ font-weight: 600;
82
+ margin: 0;
83
+ }
84
+
85
+ .canvas {
86
+ display: block;
87
+ image-rendering: pixelated;
88
+ border-radius: 8px;
89
+ }
90
+
91
+ .err {
92
+ color: #d23f3f;
93
+ font-size: 13px;
94
+ margin: 0;
95
+ }
96
+
97
+ .urlBlock {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 4px;
101
+ width: 100%;
102
+ align-items: center;
103
+ }
104
+
105
+ .urlLabel {
106
+ font-size: 11px;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.05em;
109
+ opacity: 0.6;
110
+ }
111
+
112
+ .url {
113
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
114
+ font-size: 11px;
115
+ word-break: break-all;
116
+ background: color-mix(in srgb, currentColor 6%, transparent);
117
+ padding: 6px 8px;
118
+ border-radius: 4px;
119
+ max-width: 100%;
120
+ text-align: center;
121
+ }
122
+
123
+ .hint {
124
+ font-size: 12px;
125
+ opacity: 0.7;
126
+ text-align: center;
127
+ margin: 0;
128
+ }
129
+
130
+ .close {
131
+ margin-top: 4px;
132
+ padding: 8px 16px;
133
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
134
+ background: transparent;
135
+ color: inherit;
136
+ font: inherit;
137
+ border-radius: 6px;
138
+ cursor: pointer;
139
+ }
140
+
141
+ .close:hover {
142
+ background: color-mix(in srgb, currentColor 8%, transparent);
143
+ }
144
+
145
+ .close:focus-visible {
146
+ outline: 2px solid currentColor;
147
+ outline-offset: -2px;
148
+ }
149
+
150
+ @keyframes dsh-qr-share-fade-in {
151
+ from { opacity: 0; }
152
+ to { opacity: 1; }
153
+ }
154
+
155
+ @keyframes dsh-qr-share-pop-in {
156
+ from { opacity: 0; transform: scale(0.92); }
157
+ to { opacity: 1; transform: scale(1); }
158
+ }
package/src/config.ts ADDED
@@ -0,0 +1,37 @@
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
+
17
+ /** Wire shape (defaults applied). */
18
+ export interface ResolvedConfig {
19
+ /** Master switch: false short-circuits the route to 404. */
20
+ enabled: boolean
21
+ }
22
+
23
+ const DEFAULTS: ResolvedConfig = { enabled: true }
24
+
25
+ /**
26
+ * Apply defaults + permissive coercion. Patch-time `config:` maps are
27
+ * untyped JS objects; anything truthy that isn't explicitly `false`
28
+ * counts as enabled.
29
+ * @param raw - patch-time config object (or undefined).
30
+ * @returns resolved config.
31
+ */
32
+ export function resolveConfig(raw: unknown): ResolvedConfig {
33
+ if (raw === null || typeof raw !== 'object') return DEFAULTS
34
+ const value = (raw as { enabled?: unknown }).enabled
35
+ if (value === false) return { enabled: false }
36
+ return DEFAULTS
37
+ }
@@ -0,0 +1,39 @@
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
+
13
+ /** Minimal slice of `connection.requestRejection` we consume. */
14
+ export interface ConnectionRejectionLike {
15
+ /** 401 when cookie missing/expired; 403 when Host/Origin not trusted. */
16
+ requestRejection(req: IncomingMessage): number | undefined
17
+ /** Add the fresh process token to a clean URL. */
18
+ authenticatedUrl(baseUrl: string): string
19
+ }
20
+
21
+ /** Minimal slice of `webServer.register` we consume. */
22
+ export interface WebServerLike {
23
+ register(route: {
24
+ kind: 'exact'
25
+ path: string
26
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
27
+ }): () => void
28
+ }
29
+
30
+ declare module '@deepseek-ai/cordis' {
31
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
32
+ interface Context {
33
+ connection: ConnectionRejectionLike
34
+ webServer: WebServerLike
35
+ }
36
+ }
37
+
38
+ /** Re-export for ergonomic imports. */
39
+ export type Context = import('@deepseek-ai/cordis').Context
package/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * dsh-qr-share host half.
3
+ *
4
+ * Exposes `/_qr/share` (exact HTTP route) to authenticated browsers. The
5
+ * response carries the fresh process launch token; the browser then
6
+ * composes the final URL as `${window.location.origin}/?token=${token}`
7
+ * so the QR code automatically follows whatever authority the desktop
8
+ * actually used (LAN IP, public domain, reverse-proxied sub-path on a
9
+ * port-80 host, etc.) — no DSH_PUBLIC_HOST coupling.
10
+ *
11
+ * Security: every request passes through `connection.requestRejection`,
12
+ * which applies the same trust fence as the /api gateway:
13
+ * - 403 if Host is not in `trustedHosts` (LAN literals + --trusted-host)
14
+ * - 401 if the browser cookie is missing/expired (i.e. not yet logged in)
15
+ * The token is only returned when both checks pass; the same code path
16
+ * also drives the client-side `ready` flag so the button is hidden until
17
+ * the user is actually authenticated.
18
+ */
19
+ import type { IncomingMessage, ServerResponse } from 'node:http'
20
+ import { assertNodeContext } from './invariant.ts'
21
+ import { resolveConfig, type ResolvedConfig } from './config.ts'
22
+ import type { Context } from './context-types.ts'
23
+
24
+ export const name = 'dsh-qr-share'
25
+ export { resolveConfig }
26
+ export type { ResolvedConfig } from './config.ts'
27
+
28
+ /** Required services from the cordis fiber. */
29
+ export const inject = ['connection', 'webServer'] as const
30
+
31
+ /**
32
+ * The exact HTTP route name. Hard-coded so the plugin self-documents and
33
+ * a misconfigured patch never silently mounts under a wrong path.
34
+ */
35
+ const ROUTE_PATH = '/_qr/share'
36
+
37
+ /** Wall-clock approximate TTL of the browser cookie — purely advisory. */
38
+ const ADVISORY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
39
+
40
+ /** Resolve the launch token from the live BrowserAuth, given a request. */
41
+ function resolveToken(ctx: Context, hostHeader: string): string {
42
+ const baseUrl = `http://${hostHeader}`
43
+ const launchUrl = ctx.connection.authenticatedUrl(baseUrl)
44
+ const token = new URL(launchUrl).searchParams.get('token')
45
+ return token ?? ''
46
+ }
47
+
48
+ /**
49
+ * Mount the route and return the disposer. Plugin fiber disposal removes
50
+ * it via the webServer's own bookkeeping.
51
+ * @param ctx - host cordis context (connection + webServer injected).
52
+ * @param rawConfig - patch-time config (validated by resolveConfig).
53
+ */
54
+ export function apply(ctx: Context, rawConfig: unknown): void {
55
+ assertNodeContext()
56
+ const config = resolveConfig(rawConfig)
57
+ if (!config.enabled) return
58
+
59
+ const connection = ctx.connection
60
+ const webServer = ctx.webServer
61
+
62
+ webServer.register({
63
+ kind: 'exact',
64
+ path: ROUTE_PATH,
65
+ handler: (req: IncomingMessage, res: ServerResponse): void => {
66
+ // Trust + auth gate. Returns the rejection status (401/403) or undefined.
67
+ const rejection = connection.requestRejection(req)
68
+ if (rejection !== undefined) {
69
+ // Match /api's minimal response shape: status + plain text body.
70
+ // No JSON leak about which check tripped.
71
+ res.writeHead(rejection, {
72
+ 'content-type': 'text/plain; charset=utf-8',
73
+ 'cache-control': 'no-store',
74
+ })
75
+ res.end(
76
+ rejection === 401
77
+ ? 'qr-share: authentication required\n'
78
+ : 'qr-share: forbidden\n',
79
+ )
80
+ return
81
+ }
82
+
83
+ // Use req.headers.host (the authority the browser actually used).
84
+ // Trust fence has just approved it, so we don't risk SSRF here.
85
+ const hostHeader = req.headers.host
86
+ if (typeof hostHeader !== 'string' || hostHeader === '') {
87
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
88
+ res.end('qr-share: missing Host header\n')
89
+ return
90
+ }
91
+
92
+ const token = resolveToken(ctx, hostHeader)
93
+ const ready = token !== ''
94
+
95
+ const body = JSON.stringify({
96
+ ready,
97
+ token,
98
+ tokenTtlMs: ADVISORY_TOKEN_TTL_MS,
99
+ })
100
+ res.writeHead(200, {
101
+ 'content-type': 'application/json; charset=utf-8',
102
+ 'cache-control': 'no-store',
103
+ })
104
+ res.end(body)
105
+ },
106
+ })
107
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * dsh-qr-share invariant — fails fast when the host half is loaded without
3
+ * the services it declares in `inject`. Mirrors the official DSH plugin
4
+ * invariant pattern (used by every bundle in the monorepo) so a misconfigured
5
+ * mount surfaces a precise error rather than a runtime undefined access.
6
+ */
7
+ import { createRequire } from 'node:module'
8
+
9
+ const require = createRequire(import.meta.url)
10
+
11
+ /** Throws when the import-time guard fails; never returns. */
12
+ export function assert(condition: unknown, message: string): asserts condition {
13
+ if (!condition) {
14
+ throw new Error(`dsh-qr-share: ${message}`)
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Guard `require` to the official Node context — the host half MUST NOT
20
+ * be loaded through the browser module table. The bundler's purity gate
21
+ * already blocks Node-builtin imports in the client bundles; this is the
22
+ * defense-in-depth runtime check.
23
+ */
24
+ export function assertNodeContext(): void {
25
+ // Trigger a Node-only import to fail-fast in any non-Node runtime.
26
+ require('node:fs')
27
+ }