@linxin666/dsh-remote-web-ui 0.1.17 → 0.1.19

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.
Files changed (56) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +2 -2
  4. package/lib/client.js +82 -66
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +18 -18
  7. package/lib/mobile.js +649 -45
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +11 -5
  12. package/lib/types/client/RemoteSettingsCard.d.ts +5 -0
  13. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/RemoteSettingsCard.js +7 -0
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/index.js +13 -7
  17. package/lib/types/client/settings-form.d.ts +8 -0
  18. package/lib/types/client/settings-form.d.ts.map +1 -1
  19. package/lib/types/client/settings-form.js +15 -1
  20. package/lib/types/gate.d.ts +4 -9
  21. package/lib/types/gate.d.ts.map +1 -1
  22. package/lib/types/gate.js +5 -32
  23. package/lib/types/loopback.d.ts +25 -0
  24. package/lib/types/loopback.d.ts.map +1 -0
  25. package/lib/types/loopback.js +68 -0
  26. package/lib/types/mobile/markdown.d.ts +26 -0
  27. package/lib/types/mobile/markdown.d.ts.map +1 -0
  28. package/lib/types/mobile/markdown.js +272 -0
  29. package/lib/types/mobile/markdown.test.d.ts +2 -0
  30. package/lib/types/mobile/markdown.test.d.ts.map +1 -0
  31. package/lib/types/mobile/markdown.test.js +67 -0
  32. package/lib/types/mobile/messages.d.ts +14 -0
  33. package/lib/types/mobile/messages.d.ts.map +1 -1
  34. package/lib/types/mobile/messages.js +54 -2
  35. package/lib/types/mobile/messages.test.js +99 -0
  36. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  37. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  38. package/lib/types/mobile/mobile-styles.js +167 -0
  39. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  40. package/lib/types/mobile/views/ChatView.js +81 -5
  41. package/lib/types/mobile/views/ChatView.test.js +105 -0
  42. package/package.json +1 -1
  43. package/src/client/PluginSettingsCard.tsx +50 -47
  44. package/src/client/RemoteSettingsCard.tsx +8 -0
  45. package/src/client/index.ts +13 -7
  46. package/src/client/settings-card.module.css +9 -0
  47. package/src/client/settings-form.ts +15 -1
  48. package/src/gate.ts +5 -30
  49. package/src/loopback.ts +63 -0
  50. package/src/mobile/markdown.test.ts +77 -0
  51. package/src/mobile/markdown.ts +278 -0
  52. package/src/mobile/messages.test.ts +106 -0
  53. package/src/mobile/messages.ts +66 -2
  54. package/src/mobile/mobile-styles.ts +167 -0
  55. package/src/mobile/views/ChatView.test.tsx +121 -0
  56. package/src/mobile/views/ChatView.tsx +164 -6
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
45
45
  onSave: () => void
46
46
  /** Drop every staged edit. */
47
47
  onDiscard: () => void
48
+ /**
49
+ * Render the controls expanded on arrival (still collapsible). Defaults to
50
+ * true: promoted first-level sections show their settings immediately.
51
+ */
52
+ defaultOpen?: boolean
53
+ /**
54
+ * Render without the collapse affordance: a static header and an always
55
+ * visible body. Used by first-level settings sections whose nav entry
56
+ * already provides the selection.
57
+ */
58
+ alwaysOpen?: boolean
48
59
  /** The plugin's controls. */
49
60
  children: ReactNode
50
61
  }
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
55
66
  * @returns the card, or nothing while the namespace is still loading.
56
67
  */
57
68
  export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
58
- const [open, setOpen] = useState(false)
59
- const { state } = props
69
+ const [open, setOpen] = useState(props.defaultOpen ?? true)
70
+ const { state, alwaysOpen } = props
60
71
  if (!state.available) return null
61
72
  const title = props.t(props.titleKey)
62
73
  const description = props.t(props.descriptionKey)
63
74
  const blocked = !state.dirty || state.invalid || state.saving
64
- const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
65
- // The namespace exists but the Host does not serve it to this client (the
66
- // official settings allowlist omits third-party namespaces): show a card
67
- // that explains the gap instead of vanishing, so a missing card never
68
- // reads as a missing plugin.
69
- if (!state.exposed) {
70
- return (
71
- <li className={cardClass}>
72
- <button
73
- type="button"
74
- className={css.header}
75
- aria-expanded={open}
76
- aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
77
- onClick={() => { setOpen(!open) }}
78
- >
79
- <span className={css.headText}>
80
- <span className={css.name} title={title}>{title}</span>
81
- <span className={css.description} title={description}>{description}</span>
82
- </span>
83
- <svg
84
- width="14"
85
- height="14"
86
- viewBox="0 0 14 14"
87
- fill="none"
88
- xmlns="http://www.w3.org/2000/svg"
89
- className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
90
- >
91
- <path
92
- d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
93
- fill="currentColor"
94
- />
95
- </svg>
96
- </button>
97
- {open
98
- ? (
99
- <div className={css.body}>
100
- <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
101
- </div>
102
- )
103
- : null}
104
- </li>
75
+ const expanded = alwaysOpen === true || open
76
+ const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
77
+ // With alwaysOpen the nav entry already provides the selection, so the
78
+ // header is a static title row instead of a disclosure button.
79
+ const header = alwaysOpen === true
80
+ ? (
81
+ <div className={css.headerStatic}>
82
+ <span className={css.headText}>
83
+ <span className={css.name} title={title}>{title}</span>
84
+ <span className={css.description} title={description}>{description}</span>
85
+ </span>
86
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
87
+ </div>
105
88
  )
106
- }
107
- return (
108
- <li className={cardClass}>
89
+ : (
109
90
  <button
110
91
  type="button"
111
92
  className={css.header}
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
132
113
  />
133
114
  </svg>
134
115
  </button>
135
- {open
116
+ )
117
+ // The namespace exists but the Host does not serve it to this client (the
118
+ // official settings allowlist omits third-party namespaces): show a card
119
+ // that explains the gap instead of vanishing, so a missing card never
120
+ // reads as a missing plugin.
121
+ if (!state.exposed) {
122
+ return (
123
+ <li className={cardClass}>
124
+ {header}
125
+ {expanded
126
+ ? (
127
+ <div className={css.body}>
128
+ <p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
129
+ </div>
130
+ )
131
+ : null}
132
+ </li>
133
+ )
134
+ }
135
+ return (
136
+ <li className={cardClass}>
137
+ {header}
138
+ {expanded
136
139
  ? (
137
140
  <div className={css.body}>
138
141
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
@@ -104,6 +104,14 @@ export class RemoteSettingsCardController {
104
104
  inject(): RemoteSettingsCardFace {
105
105
  return { hooks: { remoteSettingsCard: this.store }, ...this.form.actions() }
106
106
  }
107
+
108
+ /**
109
+ * Release the card's scope subscription and bound stores; the slot
110
+ * disposer calls this on teardown.
111
+ */
112
+ dispose(): void {
113
+ this.form.dispose()
114
+ }
107
115
  }
108
116
 
109
117
  /** Props the renderer binds for the remote-control card. */
@@ -157,13 +157,19 @@ export function apply(ctx: ClientContext): void {
157
157
  // Plugin configuration card: one staged form over the `remote-web-ui`
158
158
  // settings namespace, contributed to the Web UI plugin group.
159
159
  const remoteSettings = new RemoteSettingsCardController(settingsScope)
160
- ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
161
- name: 'web-ui.plugin.item',
162
- id: 'remote-web-ui',
163
- order: 90,
164
- locale: NS,
165
- inject: () => remoteSettings.inject(),
166
- }, RemoteSettingsCard))
160
+ ctx.slots.inject('web-ui.plugin.item', () => {
161
+ const unregister = ctx.slots.register({
162
+ name: 'web-ui.plugin.item',
163
+ id: 'remote-web-ui',
164
+ order: 90,
165
+ locale: NS,
166
+ inject: () => remoteSettings.inject(),
167
+ }, RemoteSettingsCard)
168
+ return () => {
169
+ remoteSettings.dispose()
170
+ unregister()
171
+ }
172
+ })
167
173
 
168
174
  // Phone-side boot flow + heartbeats. Loopback pages (the desktop) never
169
175
  // heartbeat; the server ignores unpaired heartbeats anyway. Both run only
@@ -42,6 +42,15 @@
42
42
  outline-offset: -2px;
43
43
  }
44
44
 
45
+ .headerStatic {
46
+ width: 100%;
47
+ border-radius: 12px;
48
+ align-items: center;
49
+ gap: 12px;
50
+ padding: 14px 16px;
51
+ display: flex;
52
+ }
53
+
45
54
  .headText {
46
55
  flex-direction: column;
47
56
  flex: 1;
@@ -234,6 +234,9 @@ export class CardForm<T> {
234
234
  private readonly specs: Map<string, FieldSpec>
235
235
  private readonly staged = new Map<string, StagedEdit>()
236
236
  private readonly listeners = new Set<() => void>()
237
+ /** The scope subscription installed in the constructor; released by dispose(). */
238
+ private readonly disposeScope: () => void
239
+ private disposed = false
237
240
  private saving = false
238
241
  private failed = false
239
242
  private failedReason: string | undefined
@@ -244,7 +247,18 @@ export class CardForm<T> {
244
247
  specs: FieldSpec[],
245
248
  ) {
246
249
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
247
- scope.subscribe(() => { this.publish() })
250
+ this.disposeScope = scope.subscribe(() => { this.publish() })
251
+ }
252
+
253
+ /**
254
+ * Release the scope subscription and every bound store listener. The card
255
+ * must call this on teardown; later calls are no-ops.
256
+ */
257
+ dispose(): void {
258
+ if (this.disposed) return
259
+ this.disposed = true
260
+ this.disposeScope()
261
+ this.listeners.clear()
248
262
  }
249
263
 
250
264
  /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
package/src/gate.ts CHANGED
@@ -13,41 +13,16 @@
13
13
 
14
14
  import type { IncomingMessage } from 'node:http'
15
15
  import type { PairingService } from './pairing.ts'
16
+ import { isLoopbackAddress, isLoopbackHostname } from './loopback.ts'
16
17
 
17
18
  /**
18
- * Whether a normalized URL hostname names the local loopback authority.
19
- * Semantics mirror the connection package's internal predicate (localhost,
20
- * IPv6 loopback, any IPv4 address in 127/8); it is reimplemented here because
21
- * the connection package no longer exports it — the fence now lives inside
22
- * the connection plugin, and external host plugins only need the
23
- * classification, not the whole trust decision.
19
+ * Loopback classification for the desktop client. The predicates now live in
20
+ * the shared synced copy (shared/host/loopback.ts, mirrored to ./loopback.ts
21
+ * by scripts/sync-shared.mjs): localhost, IPv6 loopback, and any IPv4 address
22
+ * in 127/8.
24
23
  * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
25
24
  * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
26
25
  */
27
- export function isLoopbackHostname(hostname: string): boolean {
28
- if (hostname === 'localhost' || hostname === '[::1]') return true
29
- const parts = hostname.split('.')
30
- return parts.length === 4
31
- && parts[0] === '127'
32
- && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
33
- }
34
-
35
- /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
36
- export function isLoopbackAddress(address: string | undefined): boolean {
37
- if (address === undefined) return false
38
- const normalized = address.toLowerCase()
39
- if (normalized === '::1') return true
40
- if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
41
- return isIPv4Loopback(normalized)
42
- }
43
-
44
- /** IPv4 127/8 predicate (four decimal octets, first == 127). */
45
- function isIPv4Loopback(v4: string): boolean {
46
- const parts = v4.split('.')
47
- return parts.length === 4
48
- && parts[0] === '127'
49
- && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
50
- }
51
26
 
52
27
  /**
53
28
  * Read one cookie value from a Cookie header.
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Mobile markdown renderer tests: GFM subset coverage plus the security
3
+ * contract — raw HTML escapes, dangerous link/image protocols are
4
+ * stripped, and code fences never parse their content.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { escapeHtml, renderInline, renderMarkdown, safeUrl } from './markdown.ts'
8
+
9
+ describe('safeUrl', () => {
10
+ it('allows http/https/mailto/fragments and scheme-less targets', () => {
11
+ expect(safeUrl('https://example.com/a')).toBe('https://example.com/a')
12
+ expect(safeUrl('http://example.com')).toBe('http://example.com')
13
+ expect(safeUrl('mailto:a@b.c')).toBe('mailto:a@b.c')
14
+ expect(safeUrl('#top')).toBe('#top')
15
+ expect(safeUrl('./rel/path')).toBe('./rel/path')
16
+ })
17
+
18
+ it('rejects javascript:/data:/vbscript: and blanks', () => {
19
+ expect(safeUrl('javascript:alert(1)')).toBeNull()
20
+ expect(safeUrl('data:text/html,x')).toBeNull()
21
+ expect(safeUrl('vbscript:x')).toBeNull()
22
+ expect(safeUrl('')).toBeNull()
23
+ expect(safeUrl(' ')).toBeNull()
24
+ })
25
+ })
26
+
27
+ describe('renderInline', () => {
28
+ it('renders code, bold, italic, strikethrough and links', () => {
29
+ expect(renderInline('`a<b`')).toBe('<code>a&lt;b</code>')
30
+ expect(renderInline('**b**')).toBe('<strong>b</strong>')
31
+ expect(renderInline('*i*')).toBe('<em>i</em>')
32
+ expect(renderInline('~~d~~')).toBe('<del>d</del>')
33
+ expect(renderInline('[x](https://e.com)')).toBe('<a href="https://e.com" target="_blank" rel="noopener noreferrer">x</a>')
34
+ })
35
+
36
+ it('strips unsafe link targets and renders the label as plain text', () => {
37
+ expect(renderInline('[x](javascript:alert(1))')).toBe('x')
38
+ expect(renderInline('![a](data:text/html,x)')).toBe('a')
39
+ })
40
+
41
+ it('escapes raw HTML instead of passing it through', () => {
42
+ expect(renderInline('<script>alert(1)</script>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;')
43
+ })
44
+ })
45
+
46
+ describe('renderMarkdown', () => {
47
+ it('renders headings, hr, paragraphs', () => {
48
+ const html = renderMarkdown('# T\n\npara\n\n---')
49
+ expect(html).toContain('<h1>T</h1>')
50
+ expect(html).toContain('<p>para</p>')
51
+ expect(html).toContain('<hr />')
52
+ })
53
+
54
+ it('renders fenced code without parsing its content', () => {
55
+ const html = renderMarkdown('```ts\nconst a = **bold** <x>\n```')
56
+ expect(html).toBe('<pre class="language-ts"><code>const a = **bold** &lt;x&gt;</code></pre>')
57
+ })
58
+
59
+ it('renders lists, blockquotes and tables', () => {
60
+ const html = renderMarkdown('- a\n- b\n\n1. x\n2. y\n\n> quote\n\n| h1 | h2 |\n| --- | --- |\n| c1 | c2 |')
61
+ expect(html).toContain('<ul><li>a</li><li>b</li></ul>')
62
+ expect(html).toContain('<ol><li>x</li><li>y</li></ol>')
63
+ expect(html).toContain('<blockquote><p>quote</p></blockquote>')
64
+ expect(html).toContain('<thead><tr><th>h1</th><th>h2</th></tr></thead>')
65
+ expect(html).toContain('<td>c1</td><td>c2</td>')
66
+ })
67
+
68
+ it('normalizes CRLF and keeps user-visible text intact', () => {
69
+ expect(renderMarkdown('a\r\nb')).toBe('<p>a\nb</p>')
70
+ })
71
+ })
72
+
73
+ describe('escapeHtml', () => {
74
+ it('escapes all five HTML-significant characters', () => {
75
+ expect(escapeHtml('<&>"\'')).toBe('&lt;&amp;&gt;&quot;&#39;')
76
+ })
77
+ })