dsh-zen-remote 1.0.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.
Files changed (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
@@ -0,0 +1,106 @@
1
+ // turn-fold — the folded turn process and its summary row (S8, 2026-08-17).
2
+ // Everything that can hide content lives inside (max-width: 767px); the two
3
+ // markers this file reads (data-mnav-fold on a process element,
4
+ // data-mobile-nav="turn-fold" on the injected summary row) are only ever
5
+ // written by effects/turn-fold.ts, which detaches and wipes them at >= 768px
6
+ // — so tablet and desktop are a no-op twice over, by attribute and by media
7
+ // query. Appended last in styles/index.ts; it shares no selector with any
8
+ // other file, the position just keeps the "phone files come after the shared
9
+ // <=1023px block" ordering intact.
10
+
11
+ export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
12
+
13
+ /* The summary row never paints outside the phone breakpoint, even if a
14
+ media-query change raced the effect's own cleanup. */
15
+ [data-mobile-nav="turn-fold"] {
16
+ display: none;
17
+ }
18
+
19
+ @media (max-width: 767px) {
20
+ /* Folded process: tool-call / context / command rows and the Think
21
+ disclosures inside an assistant step. !important because the official
22
+ flow item and ReasoningRow both set their own display. */
23
+ [data-mnav-fold]:not([data-mnav-fold-open]) {
24
+ display: none !important;
25
+ }
26
+
27
+ /* Compact chip, sized like the composer's own pills rather than a button:
28
+ it is a reading affordance in the middle of the message flow, so it has
29
+ to stay quieter than the content around it. The flow column is a flex
30
+ column with gap: 16px (ChatView.module.css) — align-self keeps the chip
31
+ from stretching, and the negative block margin trims that generous gap
32
+ back to something a 26px row can live in. */
33
+ [data-chat-flow] > [data-mobile-nav="turn-fold"] {
34
+ display: inline-flex;
35
+ align-self: flex-start;
36
+ align-items: center;
37
+ gap: 6px;
38
+ margin: -4px 0;
39
+ padding: 0 10px;
40
+ height: 26px;
41
+ border: none;
42
+ border-radius: 13px;
43
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
44
+ color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .55));
45
+ font: inherit;
46
+ font-size: 12px;
47
+ line-height: 26px;
48
+ white-space: nowrap;
49
+ cursor: pointer;
50
+ -webkit-tap-highlight-color: transparent;
51
+ }
52
+ [data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
53
+ background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));
54
+ }
55
+
56
+ /* Leading dot: idle turns get a quiet mark, a running turn gets the same
57
+ business-primary colour the session header's own status dot uses
58
+ (styles/header.css.ts) plus a breathing pulse. */
59
+ [data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
60
+ content: '';
61
+ flex: none;
62
+ width: 6px;
63
+ height: 6px;
64
+ border-radius: 50%;
65
+ background: currentColor;
66
+ opacity: .45;
67
+ }
68
+ [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
69
+ background: var(--dsw-alias-state-business-primary, #4f6ef7);
70
+ opacity: 1;
71
+ animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;
72
+ }
73
+
74
+ /* Chevron drawn from two borders — no icon injection, no mask image, and
75
+ it rotates to point up once the turn is open. */
76
+ [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
77
+ content: '';
78
+ flex: none;
79
+ width: 5px;
80
+ height: 5px;
81
+ margin-top: -3px;
82
+ border-right: 1.5px solid currentColor;
83
+ border-bottom: 1.5px solid currentColor;
84
+ transform: rotate(45deg);
85
+ transition: transform .16s var(--ds-ease-out, ease-in-out);
86
+ }
87
+ [data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
88
+ margin-top: 3px;
89
+ transform: rotate(-135deg);
90
+ }
91
+
92
+ @media (prefers-reduced-motion: reduce) {
93
+ [data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
94
+ animation: none;
95
+ }
96
+ [data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
97
+ transition: none;
98
+ }
99
+ }
100
+ }
101
+
102
+ @keyframes dsh-mobile-nav-breathe {
103
+ 0%, 100% { opacity: 1; transform: scale(1); }
104
+ 50% { opacity: .4; transform: scale(.72); }
105
+ }
106
+ `
package/src/index.ts ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * dsh-mobile-nav, node half.
3
+ *
4
+ * Was an empty apply (pure client UI plugin) until S7. It now owns ONE host
5
+ * route: the phone composer's attachment upload. The official file picker
6
+ * opens on the machine running DSH, which is useless from a phone, and the
7
+ * public client API has no upload verb at all — the only public browser->host
8
+ * byte channel is `session.prompt([{type:'image',…}])`, which is images only
9
+ * and lands as a sent message rather than a file on disk. So a non-image
10
+ * attachment needs a route of its own, and that route belongs here rather
11
+ * than in the gateway half of this plugin: the gateway authenticates and forwards
12
+ * verbatim, it does not know what a session or a workspace is.
13
+ *
14
+ * The browser half still ships via exports["./client"], discovered through
15
+ * the package.json dsh.client declaration.
16
+ */
17
+
18
+ import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises'
19
+ import type { FileHandle } from 'node:fs/promises'
20
+ import type { IncomingMessage, ServerResponse } from 'node:http'
21
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
22
+ import type { Context } from '@deepseek-ai/cordis'
23
+ import type {} from '@deepseek-ai/dsh-host-webserver'
24
+ import type {} from '@deepseek-ai/dsh-session'
25
+
26
+ /** Exact route the phone composer POSTs one file body to. */
27
+ export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload'
28
+
29
+ /** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
30
+ export const UPLOAD_DIR = '.dsh-uploads'
31
+
32
+ /** Body cap when the plugin row sets no `maxUploadBytes`. */
33
+ export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
34
+
35
+ /** Longest filename, in bytes, that survives sanitization (ext4/APFS leaf limit is 255). */
36
+ const MAX_NAME_BYTES = 180
37
+
38
+ /** Distinct leaf names tried before a collision is given up on. */
39
+ const MAX_COLLISION_TRIES = 100
40
+
41
+ /** Host half config; the only knob is the body cap. */
42
+ export interface MobileNavConfig {
43
+ /** Max upload body in bytes; larger bodies get 413. Default {@link DEFAULT_MAX_UPLOAD_BYTES}. */
44
+ maxUploadBytes?: number
45
+ }
46
+
47
+ /**
48
+ * One rejection carrying the status the client should see.
49
+ *
50
+ * Fields are assigned in the body rather than declared as constructor
51
+ * parameter properties: `scripts/check-upload-endpoint.mjs` imports this
52
+ * module through Node's strip-only type stripping, which rejects that syntax.
53
+ */
54
+ class UploadError extends Error {
55
+ readonly status: number
56
+ readonly code: string
57
+
58
+ constructor(status: number, code: string, message: string) {
59
+ super(message)
60
+ this.name = 'UploadError'
61
+ this.status = status
62
+ this.code = code
63
+ }
64
+ }
65
+
66
+ function message(error: unknown): string {
67
+ return error instanceof Error ? error.message : String(error)
68
+ }
69
+
70
+ function isErrnoCode(error: unknown, code: string): boolean {
71
+ return error instanceof Error && 'code' in error && (error as { code?: unknown }).code === code
72
+ }
73
+
74
+ function responseJson(res: ServerResponse, status: number, body: unknown): void {
75
+ const bytes = Buffer.from(JSON.stringify(body))
76
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
77
+ res.setHeader('Content-Length', String(bytes.length))
78
+ res.setHeader('Cache-Control', 'no-store')
79
+ res.setHeader('X-Content-Type-Options', 'nosniff')
80
+ res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
81
+ res.writeHead(status)
82
+ res.end(bytes)
83
+ }
84
+
85
+ /**
86
+ * Accept a state-changing request only from this DSH Web application's origin.
87
+ *
88
+ * The gateway half rewrites `Origin`/`Host` to the upstream origin
89
+ * before forwarding (lan-gate-server.cjs `cleanHeaders`), so a phone request
90
+ * that already cleared the pairing wall presents here as same-origin; a
91
+ * request with neither header falls back to the Fetch metadata.
92
+ * @param req - the inbound request.
93
+ * @returns true when the request may mutate the workspace.
94
+ */
95
+ export function sameOriginPost(req: IncomingMessage): boolean {
96
+ const fetchSite = req.headers['sec-fetch-site']
97
+ if (fetchSite === 'cross-site') return false
98
+ const origin = req.headers.origin
99
+ if (origin === undefined) return fetchSite === 'same-origin' || fetchSite === 'same-site' || fetchSite === 'none'
100
+ const host = req.headers.host
101
+ if (host === undefined) return false
102
+ try {
103
+ const parsed = new URL(origin)
104
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Reject a resolved path that is not rooted below the expected directory.
112
+ * @param root - the directory the target must stay inside.
113
+ * @param target - the resolved candidate path.
114
+ * @throws when the target escapes the root.
115
+ */
116
+ export function ensurePathInside(root: string, target: string): void {
117
+ const rel = relative(root, target)
118
+ if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
119
+ throw new UploadError(400, 'path-escape', `resolved upload path escapes its workspace root: ${target}`)
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Convert an untrusted browser label into one portable leaf filename.
125
+ *
126
+ * Everything that could steer the write out of the upload directory is gone
127
+ * after this: only the basename survives (so `../../etc/passwd` becomes
128
+ * `passwd`), separators and control characters become `_`, leading dots are
129
+ * dropped, and the Windows reserved device names are prefixed. Whitespace
130
+ * folds to `_` rather than being kept: the client appends the result to the
131
+ * composer draft as an `@path` mention, and a mention with a space in it is
132
+ * broken for the agent reading it, not just for the chip parser. Length is
133
+ * capped in BYTES because the label arrives as UTF-8.
134
+ * @param raw - browser-supplied filename.
135
+ * @returns a single safe leaf name, never empty.
136
+ */
137
+ export function safeUploadName(raw: string): string {
138
+ const leaf = basename(raw.replaceAll('\\', '/')).normalize('NFC')
139
+ let cleaned = leaf
140
+ .replace(/[<>:"|?*\u0000-\u001f/\\]/gu, '_')
141
+ .replace(/\s+/gu, '_')
142
+ .replace(/^\.+/u, '')
143
+ .trim()
144
+ .replace(/[. ]+$/u, '')
145
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)) cleaned = `_${cleaned}`
146
+ const candidate = cleaned === '' ? 'upload.bin' : cleaned
147
+ if (Buffer.byteLength(candidate) <= MAX_NAME_BYTES) return candidate
148
+ const extension = extname(candidate).slice(0, 20)
149
+ const budget = Math.max(1, MAX_NAME_BYTES - Buffer.byteLength(extension))
150
+ let stem = candidate.slice(0, Math.max(1, candidate.length - extension.length))
151
+ while (Buffer.byteLength(stem) > budget) stem = stem.slice(0, -1)
152
+ return `${stem}${extension}`
153
+ }
154
+
155
+ function singleQuery(url: URL, key: string): string {
156
+ const values = url.searchParams.getAll(key)
157
+ const value = values[0]
158
+ if (values.length !== 1 || value === undefined || value === '') {
159
+ throw new UploadError(400, 'bad-request', `${key} is required exactly once`)
160
+ }
161
+ return value
162
+ }
163
+
164
+ /** Create the directory if absent, then prove it is a real directory inside the workspace. */
165
+ async function ensureManagedDirectory(workspace: string, path: string): Promise<string> {
166
+ try {
167
+ await mkdir(path, { mode: 0o700 })
168
+ } catch (error) {
169
+ if (!isErrnoCode(error, 'EEXIST')) throw error
170
+ }
171
+ const entry = await lstat(path)
172
+ // A symlink here would be the one way a prior workspace write could still
173
+ // redirect the bytes elsewhere — realpath alone would happily follow it.
174
+ if (entry.isSymbolicLink()) {
175
+ throw new UploadError(400, 'path-escape', `upload directory is a symbolic link: ${path}`)
176
+ }
177
+ if (!entry.isDirectory()) throw new UploadError(400, 'path-escape', `upload path is not a directory: ${path}`)
178
+ const canonical = await realpath(path)
179
+ ensurePathInside(workspace, canonical)
180
+ return canonical
181
+ }
182
+
183
+ interface UploadRoot {
184
+ /** Canonical directory the bytes are written into. */
185
+ writeRoot: string
186
+ /** The same directory as the user sees it (pre-realpath), for the returned relative path. */
187
+ visibleRoot: string
188
+ }
189
+
190
+ /**
191
+ * Resolve (and create) the upload directory of one live session.
192
+ * @param ctx - host context carrying the sessions service.
193
+ * @param sessionId - the session whose workspace receives the file.
194
+ * @returns the canonical and visible upload directories.
195
+ * @throws 404 when no live session has that id.
196
+ */
197
+ async function sessionUploadRoot(ctx: Context, sessionId: string): Promise<UploadRoot> {
198
+ const session = ctx.sessions.get(sessionId as never)
199
+ if (session === undefined) throw new UploadError(404, 'session-not-found', `live Session not found: ${sessionId}`)
200
+ const cwd = session.header.cwd
201
+ if (cwd === undefined || !isAbsolute(cwd)) {
202
+ throw new UploadError(404, 'session-not-found', `Session has no absolute workspace: ${sessionId}`)
203
+ }
204
+ const visibleWorkspace = resolve(cwd)
205
+ const workspace = await realpath(visibleWorkspace)
206
+ const visibleRoot = join(visibleWorkspace, UPLOAD_DIR)
207
+ const writeRoot = await ensureManagedDirectory(workspace, visibleRoot)
208
+ return { writeRoot, visibleRoot }
209
+ }
210
+
211
+ /**
212
+ * Claim one not-yet-existing leaf name, suffixing `-1`, `-2`, … on collision.
213
+ * `wx` makes the claim atomic, so two concurrent uploads of the same name
214
+ * cannot both win the same path.
215
+ */
216
+ async function openUnique(directory: string, filename: string): Promise<{ handle: FileHandle; path: string }> {
217
+ const extension = extname(filename)
218
+ const stem = filename.slice(0, filename.length - extension.length) || 'upload'
219
+ for (let n = 0; n < MAX_COLLISION_TRIES; n += 1) {
220
+ const path = join(directory, n === 0 ? `${stem}${extension}` : `${stem}-${n}${extension}`)
221
+ ensurePathInside(directory, path)
222
+ try {
223
+ return { handle: await open(path, 'wx', 0o600), path }
224
+ } catch (error) {
225
+ if (!isErrnoCode(error, 'EEXIST')) throw error
226
+ }
227
+ }
228
+ throw new UploadError(409, 'name-taken', `too many files named like ${filename}`)
229
+ }
230
+
231
+ /**
232
+ * Stream the request body onto disk under a running byte cap.
233
+ * @param req - the request whose body is the file.
234
+ * @param directory - canonical upload directory.
235
+ * @param filename - sanitized leaf name.
236
+ * @param maxBytes - hard cap; exceeding it aborts and unlinks.
237
+ * @returns the absolute path written and its byte count.
238
+ */
239
+ async function writeUpload(
240
+ req: IncomingMessage,
241
+ directory: string,
242
+ filename: string,
243
+ maxBytes: number,
244
+ ): Promise<{ path: string; bytes: number }> {
245
+ const declared = req.headers['content-length']
246
+ const expected = declared === undefined ? undefined : Number(declared)
247
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0)) {
248
+ throw new UploadError(400, 'bad-request', 'Content-Length is not a byte count')
249
+ }
250
+ // Reject the oversized upload before a single byte is read, so the phone
251
+ // gets its 413 without spending the whole body on the radio.
252
+ if (expected !== undefined && expected > maxBytes) {
253
+ throw new UploadError(413, 'too-large', `upload exceeds the ${maxBytes}-byte limit`)
254
+ }
255
+
256
+ const { handle, path } = await openUnique(directory, filename)
257
+ let received = 0
258
+ try {
259
+ for await (const chunk of req) {
260
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
261
+ received += bytes.length
262
+ // Chunked bodies declare no length, so the cap must also hold here.
263
+ if (received > maxBytes) throw new UploadError(413, 'too-large', `upload exceeds the ${maxBytes}-byte limit`)
264
+ await handle.write(bytes)
265
+ }
266
+ if (expected !== undefined && received !== expected) {
267
+ throw new UploadError(400, 'truncated', `upload body size mismatch: expected ${expected}, received ${received}`)
268
+ }
269
+ await handle.close()
270
+ return { path, bytes: received }
271
+ } catch (error) {
272
+ await handle.close().catch(() => {})
273
+ await rm(path, { force: true }).catch(() => {})
274
+ throw error
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Handle one `POST {@link UPLOAD_ROUTE}?session=<id>&name=<file>` request.
280
+ *
281
+ * Exported so an integration check can drive it with a plain node:http server
282
+ * and a fake sessions service instead of booting a harness.
283
+ * @param ctx - host context carrying the sessions service and logger.
284
+ * @param maxBytes - body cap.
285
+ * @param req - inbound request; its body is the raw file.
286
+ * @param res - the response this call owns end to end.
287
+ */
288
+ export async function handleUpload(
289
+ ctx: Context,
290
+ maxBytes: number,
291
+ req: IncomingMessage,
292
+ res: ServerResponse,
293
+ ): Promise<void> {
294
+ if (req.method !== 'POST') {
295
+ res.setHeader('Allow', 'POST')
296
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
297
+ return
298
+ }
299
+ if (!sameOriginPost(req)) {
300
+ const error = { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' }
301
+ responseJson(res, 403, { ok: false, error })
302
+ return
303
+ }
304
+ try {
305
+ const url = new URL(req.url ?? UPLOAD_ROUTE, 'http://dsh.internal')
306
+ const sessionId = singleQuery(url, 'session')
307
+ const filename = safeUploadName(singleQuery(url, 'name'))
308
+ const root = await sessionUploadRoot(ctx, sessionId)
309
+ const written = await writeUpload(req, root.writeRoot, filename, maxBytes)
310
+ const leaf = basename(written.path)
311
+ responseJson(res, 201, {
312
+ ok: true,
313
+ relPath: `${UPLOAD_DIR}/${leaf}`,
314
+ absolutePath: join(root.visibleRoot, leaf),
315
+ filename: leaf,
316
+ bytes: written.bytes,
317
+ })
318
+ } catch (error) {
319
+ const status = error instanceof UploadError ? error.status : 400
320
+ const code = error instanceof UploadError ? error.code : 'upload-rejected'
321
+ ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error))
322
+ responseJson(res, status, { ok: false, error: { code, message: message(error) } })
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Host half: mount the upload route wherever a webServer and live sessions
328
+ * exist. Both are injected INSIDE apply rather than declared as a top-level
329
+ * `inject`, so the plugin row still loads (and the browser half still ships)
330
+ * in a composition without them — Electron carries no webServer.
331
+ * @param ctx - host plugin context.
332
+ * @param config - optional body cap override.
333
+ */
334
+ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
335
+ const maxBytes = config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES
336
+ ctx.inject(['webServer', 'sessions'], (webCtx) => {
337
+ webCtx.effect(() => webCtx.webServer.register({
338
+ kind: 'exact',
339
+ path: UPLOAD_ROUTE,
340
+ handler: (req, res) => handleUpload(webCtx, maxBytes, req, res),
341
+ }), 'dsh-mobile-nav: upload route')
342
+ })
343
+ }