dsh-cds-theme 0.0.0-stage → 0.1.1
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/README.md +624 -3
- package/assets/appearance-icon.svg +4 -0
- package/assets/preview.html +241 -0
- package/client/client.js +5182 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/lib/index.js +79 -0
- package/lib/paste-route.js +333 -0
- package/lib/paste-store.js +130 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -4
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36" role="img" aria-label="Claude Design System theme">
|
|
2
|
+
<title>Claude Design System theme</title>
|
|
3
|
+
<!-- Two offset surfaces: the CDS layering idea, read as a silhouette at 36px. -->
|
|
4
|
+
<rect x="5.5" y="7.5" width="15" height="15" rx="4.5" fill="#8f8880" fill-opacity="0.45"/>
|
|
5
|
+
<rect x="15.5" y="13.5" width="15" height="15" rx="4.5" fill="#d97757"/>
|
|
6
|
+
<circle cx="23" cy="21" r="2.6" fill="#ffffff" fill-opacity="0.9"/>
|
|
7
|
+
</svg>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cds-theme Host half.
|
|
3
|
+
*
|
|
4
|
+
* The theme itself is a pure browser concern: every token and rule it owns is
|
|
5
|
+
* applied by the Client half. This Host entry exists because a bundle patch
|
|
6
|
+
* inserts a Loader row, and that row resolves the package's main entry.
|
|
7
|
+
*
|
|
8
|
+
* ONE SEAM, owned by the Host: a `webserver/index-inject` global carrying the
|
|
9
|
+
* absolute `file://` fonts base, computed here from `import.meta.url` so the
|
|
10
|
+
* committed client artifact carries no path.
|
|
11
|
+
*
|
|
12
|
+
* AND ONE ROUTE, also owned by the Host: the long-paste store
|
|
13
|
+
* (`paste-route.js`). A long paste must become a real file in the Session
|
|
14
|
+
* workspace before the composer can render it as an `@path` reference chip,
|
|
15
|
+
* and only the Host can write files — the browser cannot. The route lives in
|
|
16
|
+
* its own module, with its own fence and its own tests; this entry calls its
|
|
17
|
+
* installer and nothing else.
|
|
18
|
+
*
|
|
19
|
+
* NO FONT ROUTE, AND THAT IS DELIBERATE. This plugin used to register six
|
|
20
|
+
* `webServer` routes serving `/dsh-cds-theme/fonts/<file>`. They are gone
|
|
21
|
+
* because **the branch that would use them cannot be built**:
|
|
22
|
+
*
|
|
23
|
+
* - `client/src/font-assets.js` imports the six woff2 binaries, and
|
|
24
|
+
* `tools/build-client.mjs` sets `loader: { '.woff2': 'dataurl' }`, so every
|
|
25
|
+
* `@font-face` `src` in the shipped artifact is an inlined `data:` URL.
|
|
26
|
+
* Verified against the built bundle: six `data:font/woff2` URLs and zero
|
|
27
|
+
* route URLs in the very CSS the app injects.
|
|
28
|
+
* - `fontSourcesFor()` only falls back to the route when a face is NOT in the
|
|
29
|
+
* inline table. That happens only in a plain Node ESM run (the unit tests),
|
|
30
|
+
* where `.woff2` cannot be imported — and a Node run serves no HTTP fonts.
|
|
31
|
+
* - A build that LACKS the binaries does not produce a route-based bundle; it
|
|
32
|
+
* fails outright (`esbuild` cannot resolve the import), as verified. So there
|
|
33
|
+
* is no shipped configuration in which the route is the live path.
|
|
34
|
+
* - And the route could not have served the desktop shell anyway: that
|
|
35
|
+
* document's origin is `dsh-app://app`, where a route-relative URL resolves
|
|
36
|
+
* into the app protocol handler and 404s.
|
|
37
|
+
*
|
|
38
|
+
* Keeping six dead HTTP routes alive would be pure attack surface for zero
|
|
39
|
+
* rendered glyphs. The `file://` global stays because it is the fallback the
|
|
40
|
+
* source-run path uses.
|
|
41
|
+
*
|
|
42
|
+
* The seam does not start anything at import time, and it lives in a plain
|
|
43
|
+
* Cordis event listener the carrier owns.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import path from 'node:path'
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
48
|
+
|
|
49
|
+
import { PASTE_PATH, installPasteRoute } from './paste-route.js'
|
|
50
|
+
|
|
51
|
+
const FONTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fonts')
|
|
52
|
+
|
|
53
|
+
/** Mirrors `FONT_BASE_GLOBAL` in `client/src/fonts.js`. */
|
|
54
|
+
export const FONT_BASE_GLOBAL = '__CDS_THEME_FONT_BASE__'
|
|
55
|
+
|
|
56
|
+
/** Absolute `file://` base of the shipped fonts, with a trailing slash. */
|
|
57
|
+
export const FONT_BASE_URL = pathToFileURL(FONTS_DIR).href + '/'
|
|
58
|
+
|
|
59
|
+
export { PASTE_PATH }
|
|
60
|
+
|
|
61
|
+
export const name = 'dsh-cds-theme'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Publish the local fonts base into the document, and mount the paste store.
|
|
65
|
+
*
|
|
66
|
+
* The listener needs no service: it is a plain Cordis event the carrier fires
|
|
67
|
+
* while rendering the page. The paste route needs a web server and degrades to
|
|
68
|
+
* "long pastes stay plain text" when the composition has none.
|
|
69
|
+
*/
|
|
70
|
+
export function apply(ctx) {
|
|
71
|
+
ctx.on('webserver/index-inject', (table) => {
|
|
72
|
+
table.push({
|
|
73
|
+
kind: 'global',
|
|
74
|
+
name: FONT_BASE_GLOBAL,
|
|
75
|
+
value: FONT_BASE_URL,
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
installPasteRoute(ctx)
|
|
79
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host route: turn a pasted long text into a real file under the session
|
|
3
|
+
* workspace, and answer with the `@relative` mention the composer should carry.
|
|
4
|
+
*
|
|
5
|
+
* WHY A ROUTE. The renderer cannot write files (`ctx.fs` is a Host service; the
|
|
6
|
+
* desktop shell exposes no filesystem bridge to the page). A same-origin POST is
|
|
7
|
+
* the smallest Host seam that also works in both supported surfaces: the plain
|
|
8
|
+
* Web GUI document (`http://127.0.0.1:<port>`, same origin) and the Desktop
|
|
9
|
+
* shell (`dsh-app://app`, whose protocol handler forwards every non-asset path
|
|
10
|
+
* to the Host's own listener, see `lib/main.js` `forwardWebRequest`).
|
|
11
|
+
*
|
|
12
|
+
* THE FENCE. This route writes to disk, so it is reachable only by our own
|
|
13
|
+
* client: the listener's own authority is the reference, `sec-fetch-site` and
|
|
14
|
+
* `origin` must be absent or same-origin when present (the Desktop forwarder
|
|
15
|
+
* strips both, which is why they are optional rather than required), and a
|
|
16
|
+
* custom non-safelisted header must be present — no `<img>`, `<script>`,
|
|
17
|
+
* `<form>`, navigation, or cross-origin `fetch` can set one without a CORS
|
|
18
|
+
* preflight this server never answers. The same fence ships in
|
|
19
|
+
* `plugins/system-fonts/lib/font-route.js` and `plugins/usage-stats`.
|
|
20
|
+
*
|
|
21
|
+
* THE WORKSPACE. The Client sends only a Session id; the Host resolves the
|
|
22
|
+
* absolute workspace itself (`sessions` for a live Session,
|
|
23
|
+
* `sessionController.inspect` for a persisted one) and writes under
|
|
24
|
+
* `<cwd>/.dsh-paste/`. A browser-supplied path never reaches `fs`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { PASTE_DIR, MAX_PASTE_BYTES, writePasteFile, formatPasteMention } from './paste-store.js'
|
|
28
|
+
|
|
29
|
+
/** Route the client POSTs one converted paste to. */
|
|
30
|
+
export const PASTE_PATH = '/dsh-cds-theme/paste'
|
|
31
|
+
|
|
32
|
+
/** The marker every legitimate request carries (must match `client/src/paste.js`). */
|
|
33
|
+
export const MARKER_HEADER = 'x-dsh-cds-theme'
|
|
34
|
+
export const MARKER_VALUE = '1'
|
|
35
|
+
|
|
36
|
+
/** Refuse a request body larger than this (JSON envelope included). */
|
|
37
|
+
export const MAX_BODY_BYTES = MAX_PASTE_BYTES + 64 * 1024
|
|
38
|
+
|
|
39
|
+
function sendJson(response, status, body) {
|
|
40
|
+
const text = JSON.stringify(body)
|
|
41
|
+
response.writeHead(status, {
|
|
42
|
+
'content-type': 'application/json; charset=utf-8',
|
|
43
|
+
'content-length': Buffer.byteLength(text),
|
|
44
|
+
'cache-control': 'no-store',
|
|
45
|
+
'x-content-type-options': 'nosniff',
|
|
46
|
+
})
|
|
47
|
+
response.end(text)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------ request fence
|
|
51
|
+
|
|
52
|
+
function headerOf(request, name) {
|
|
53
|
+
const headers = request && request.headers
|
|
54
|
+
if (!headers || typeof headers !== 'object') return undefined
|
|
55
|
+
const value = headers[name]
|
|
56
|
+
if (Array.isArray(value)) return value.length === 1 ? String(value[0]) : undefined
|
|
57
|
+
return typeof value === 'string' ? value : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function authorityOf(text, scheme) {
|
|
61
|
+
if (typeof text !== 'string' || text === '') return null
|
|
62
|
+
const absolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(text)
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(absolute ? text : (scheme || 'http:') + '//' + text)
|
|
65
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
66
|
+
return url.host.toLowerCase()
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hostnameOf(authority) {
|
|
73
|
+
const at = authority.lastIndexOf(':')
|
|
74
|
+
return at === -1 ? authority : authority.slice(0, at)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function portOf(authority) {
|
|
78
|
+
const at = authority.lastIndexOf(':')
|
|
79
|
+
return at === -1 ? '' : authority.slice(at + 1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isLoopbackAuthority(authority) {
|
|
83
|
+
const host = hostnameOf(authority)
|
|
84
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Decide whether a request may write one paste file.
|
|
89
|
+
*
|
|
90
|
+
* Every branch fails CLOSED; the reference authority is the listener's own
|
|
91
|
+
* origin (`ctx.webServer.getOrigin()`), whose Host header a DNS rebinding
|
|
92
|
+
* attacker cannot forge. A not-yet-known origin falls back to the strictest
|
|
93
|
+
* loopback rule instead of opening up.
|
|
94
|
+
*
|
|
95
|
+
* @param request - node:http request (only `headers` is read).
|
|
96
|
+
* @param origin - the listener's own `http://host:port`, or null when unknown.
|
|
97
|
+
* @returns null when the request is ours, else a short machine-readable reason.
|
|
98
|
+
*/
|
|
99
|
+
export function refuseRequest(request, origin) {
|
|
100
|
+
const host = headerOf(request, 'host')
|
|
101
|
+
if (host === undefined) return 'no-host'
|
|
102
|
+
const authority = authorityOf(host, 'http:')
|
|
103
|
+
if (authority === null) return 'bad-host'
|
|
104
|
+
|
|
105
|
+
const own = origin === null || origin === undefined ? null : authorityOf(String(origin), 'http:')
|
|
106
|
+
if (own === null) {
|
|
107
|
+
if (!isLoopbackAuthority(authority)) return 'foreign-host'
|
|
108
|
+
} else if (authority !== own) {
|
|
109
|
+
const sameLoopbackPeer = isLoopbackAuthority(authority)
|
|
110
|
+
&& isLoopbackAuthority(own)
|
|
111
|
+
&& portOf(authority) === portOf(own)
|
|
112
|
+
if (!sameLoopbackPeer) return 'foreign-host'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const site = headerOf(request, 'sec-fetch-site')
|
|
116
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none') return 'cross-site'
|
|
117
|
+
|
|
118
|
+
const requestOrigin = headerOf(request, 'origin')
|
|
119
|
+
if (requestOrigin !== undefined && authorityOf(requestOrigin, 'http:') !== authority) return 'foreign-origin'
|
|
120
|
+
|
|
121
|
+
if (headerOf(request, MARKER_HEADER) !== MARKER_VALUE) return 'missing-marker'
|
|
122
|
+
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -------------------------------------------------------------- body reader
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read a bounded JSON body. The Client sends one small object; anything else is
|
|
130
|
+
* a bug or an attack, and neither deserves streaming.
|
|
131
|
+
*
|
|
132
|
+
* @param request - node:http request.
|
|
133
|
+
* @param limit - byte cap.
|
|
134
|
+
* @returns the parsed value.
|
|
135
|
+
* @throws Error with `code === 'TOO_LARGE'` past the cap, or a parse error.
|
|
136
|
+
*/
|
|
137
|
+
export function readJsonBody(request, limit = MAX_BODY_BYTES) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const chunks = []
|
|
140
|
+
let total = 0
|
|
141
|
+
let failed = false
|
|
142
|
+
const fail = (error) => {
|
|
143
|
+
if (failed) return
|
|
144
|
+
failed = true
|
|
145
|
+
reject(error)
|
|
146
|
+
}
|
|
147
|
+
request.on('data', (chunk) => {
|
|
148
|
+
if (failed) return
|
|
149
|
+
total += chunk.length
|
|
150
|
+
if (total > limit) {
|
|
151
|
+
const error = new Error('paste: request body too large')
|
|
152
|
+
error.code = 'TOO_LARGE'
|
|
153
|
+
fail(error)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
chunks.push(chunk)
|
|
157
|
+
})
|
|
158
|
+
request.on('error', fail)
|
|
159
|
+
request.on('end', () => {
|
|
160
|
+
if (failed) return
|
|
161
|
+
try {
|
|
162
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
163
|
+
} catch (error) {
|
|
164
|
+
fail(error)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------ workspace
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve one Session's absolute workspace without activating an Agent.
|
|
174
|
+
*
|
|
175
|
+
* @param ctx - host context carrying `sessions` and/or `sessionController`.
|
|
176
|
+
* @param sessionId - durable Session identity from the Client.
|
|
177
|
+
* @returns the absolute cwd, or null when the Session is unknown/workspaceless.
|
|
178
|
+
*/
|
|
179
|
+
export async function sessionWorkspace(ctx, sessionId) {
|
|
180
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions
|
|
181
|
+
const attached = sessions !== undefined && sessions !== null && typeof sessions.get === 'function'
|
|
182
|
+
? sessions.get(sessionId)
|
|
183
|
+
: undefined
|
|
184
|
+
const live = attached === undefined || attached === null ? undefined : attached.header
|
|
185
|
+
if (live !== undefined && live !== null && typeof live.cwd === 'string') return live.cwd
|
|
186
|
+
|
|
187
|
+
const controller = typeof ctx.get === 'function' ? ctx.get('sessionController') : ctx.sessionController
|
|
188
|
+
if (controller !== undefined && controller !== null && typeof controller.inspect === 'function') {
|
|
189
|
+
const inspection = await controller.inspect(sessionId)
|
|
190
|
+
const meta = inspection === undefined || inspection === null ? undefined : inspection.meta
|
|
191
|
+
if (meta !== undefined && meta !== null && typeof meta.cwd === 'string') return meta.cwd
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -------------------------------------------------------------- the route
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The paste store route.
|
|
200
|
+
*
|
|
201
|
+
* @param options.resolveWorkspace - `(ctx, sessionId) => Promise<string|null>`,
|
|
202
|
+
* injectable so the route is testable without a session store.
|
|
203
|
+
* @param options.origin - returns the listener's own origin (or null).
|
|
204
|
+
* @param options.store - `writePasteFile`-shaped writer, injectable for tests.
|
|
205
|
+
*/
|
|
206
|
+
export function createPasteRoute(ctx, options = {}) {
|
|
207
|
+
const resolve = typeof options.resolveWorkspace === 'function'
|
|
208
|
+
? options.resolveWorkspace
|
|
209
|
+
: (hostCtx, sessionId) => sessionWorkspace(hostCtx, sessionId)
|
|
210
|
+
const originOf = typeof options.origin === 'function' ? options.origin : () => null
|
|
211
|
+
const store = typeof options.store === 'function' ? options.store : writePasteFile
|
|
212
|
+
|
|
213
|
+
function listenerOrigin() {
|
|
214
|
+
try {
|
|
215
|
+
const value = originOf()
|
|
216
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
kind: 'exact',
|
|
224
|
+
path: PASTE_PATH,
|
|
225
|
+
handler: async (request, response) => {
|
|
226
|
+
if (request.method !== 'POST') {
|
|
227
|
+
response.writeHead(405, { allow: 'POST' })
|
|
228
|
+
response.end()
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
// Refuse before reading a body: a refused request must cost nothing.
|
|
232
|
+
const refused = refuseRequest(request, listenerOrigin())
|
|
233
|
+
if (refused !== null) {
|
|
234
|
+
sendJson(response, 403, { error: 'refused', reason: refused })
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let body
|
|
239
|
+
try {
|
|
240
|
+
body = await readJsonBody(request)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const tooLarge = error && error.code === 'TOO_LARGE'
|
|
243
|
+
sendJson(response, tooLarge ? 413 : 400, {
|
|
244
|
+
ok: false,
|
|
245
|
+
reason: tooLarge ? 'too-large' : 'bad-json',
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if (body === null || typeof body !== 'object') {
|
|
250
|
+
sendJson(response, 400, { ok: false, reason: 'bad-body' })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
254
|
+
const name = typeof body.name === 'string' ? body.name : ''
|
|
255
|
+
const text = typeof body.text === 'string' ? body.text : ''
|
|
256
|
+
if (sessionId === '' || name === '' || text === '') {
|
|
257
|
+
sendJson(response, 400, { ok: false, reason: 'bad-body' })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_PASTE_BYTES) {
|
|
261
|
+
sendJson(response, 413, { ok: false, reason: 'too-large' })
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let cwd
|
|
266
|
+
try {
|
|
267
|
+
cwd = await resolve(ctx, sessionId)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
cwd = null
|
|
270
|
+
}
|
|
271
|
+
if (typeof cwd !== 'string' || cwd === '') {
|
|
272
|
+
// No workspace (unknown Session, a session-less composer): the Client
|
|
273
|
+
// falls back to an ordinary text paste. Never an error the user sees.
|
|
274
|
+
sendJson(response, 409, { ok: false, reason: 'no-workspace' })
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const written = await store({ cwd, name, text })
|
|
280
|
+
const mention = formatPasteMention(written.relativePath)
|
|
281
|
+
if (mention === null) {
|
|
282
|
+
sendJson(response, 422, { ok: false, reason: 'unrepresentable-path' })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
sendJson(response, 200, {
|
|
286
|
+
ok: true,
|
|
287
|
+
fileName: written.fileName,
|
|
288
|
+
relativePath: written.relativePath,
|
|
289
|
+
mention,
|
|
290
|
+
})
|
|
291
|
+
} catch (error) {
|
|
292
|
+
sendJson(response, 500, {
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: 'write-failed',
|
|
295
|
+
error: error instanceof Error ? error.message : String(error),
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Mount the route once the profile composes a web server.
|
|
304
|
+
*
|
|
305
|
+
* `ctx.inject` waits for the service, so a composition without a web server (a
|
|
306
|
+
* headless run) simply never mounts the route and the Client's paste falls back
|
|
307
|
+
* to plain text — the feature degrades, the plugin never fails to load.
|
|
308
|
+
*
|
|
309
|
+
* @param ctx - host context of this plugin.
|
|
310
|
+
*/
|
|
311
|
+
export function installPasteRoute(ctx) {
|
|
312
|
+
if (ctx === undefined || ctx === null || typeof ctx.inject !== 'function') return
|
|
313
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
314
|
+
const webServer = hostCtx.webServer
|
|
315
|
+
if (webServer === undefined || typeof webServer.register !== 'function') {
|
|
316
|
+
console.warn('dsh-cds-theme: the webServer service exposes no register(); long pastes stay plain text')
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
const origin = () => {
|
|
320
|
+
try {
|
|
321
|
+
return typeof webServer.getOrigin === 'function' ? webServer.getOrigin() : null
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
hostCtx.effect(
|
|
327
|
+
() => webServer.register(createPasteRoute(hostCtx, { origin })),
|
|
328
|
+
'dsh-cds-theme: paste store route',
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export { PASTE_DIR }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-paste scratch store: the file half of "a long paste becomes an `@path`".
|
|
3
|
+
*
|
|
4
|
+
* WHY A FILE ON DISK. A pasted FILE is the only thing the composer renders as
|
|
5
|
+
* a compact chip: the host's paste/drop intake (verified in the installed
|
|
6
|
+
* `@deepseek-ai/dsh-client-ui-conversation` bundle, the `PASTE_COMMAND` handler
|
|
7
|
+
* around `registerComposerKeymap`) asks the desktop bridge for the real path of
|
|
8
|
+
* every pasted `File`:
|
|
9
|
+
*
|
|
10
|
+
* const path = bridge?.pathFor(file) ?? ''
|
|
11
|
+
* if (path === '' || (!directory && isImageMediaType(file.type))) {
|
|
12
|
+
* uploads.push(file) // → 240×64 upload card
|
|
13
|
+
* continue
|
|
14
|
+
* }
|
|
15
|
+
* const mention = formatFileMention({ path: relativizeToCwd(path, cwd), kind: 'file' }, false)
|
|
16
|
+
* references.push({ source: 'reference', ref: mention, … }) // → @-reference chip
|
|
17
|
+
* …
|
|
18
|
+
* shell.addFiles(references, []) // → insertFileReferences → chip node
|
|
19
|
+
*
|
|
20
|
+
* `bridge.pathFor` is `electron.webUtils.getPathForFile`, which only answers for
|
|
21
|
+
* files the OS handed to the renderer; a `File` built in JavaScript always gets
|
|
22
|
+
* `''`. So a plugin cannot synthesize the bridge answer — it must make the path
|
|
23
|
+
* REAL: write the text to disk here, and let the Client insert the `@relative`
|
|
24
|
+
* reference itself (see `client/src/paste.js`).
|
|
25
|
+
*
|
|
26
|
+
* WHERE. `<session workspace>/.dsh-paste/`, one file per paste, named by the
|
|
27
|
+
* Client (timestamped, locale-shaped) and never overwritten: the open uses
|
|
28
|
+
* `wx` and a numbered suffix retries a name collision. The directory is a
|
|
29
|
+
* visible dot-directory so the user can find and delete these files, and so
|
|
30
|
+
* `@.dsh-paste/…` resolves like any other workspace-relative mention.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IS RETURNED. Only the workspace-relative path (`/` separators) and the
|
|
33
|
+
* file name. The absolute path never leaves the Host: it is a filesystem-layout
|
|
34
|
+
* disclosure the browser has no use for.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { mkdir, open, unlink } from 'node:fs/promises'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
|
|
40
|
+
/** Directory (relative to the session workspace) holding converted pastes. */
|
|
41
|
+
export const PASTE_DIR = '.dsh-paste'
|
|
42
|
+
|
|
43
|
+
/** Refuse a paste whose UTF-8 body exceeds this. */
|
|
44
|
+
export const MAX_PASTE_BYTES = 4 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/** Longest file name accepted from the Client, in characters. */
|
|
47
|
+
export const MAX_PASTE_NAME = 180
|
|
48
|
+
|
|
49
|
+
/** Same reserved-device rule the Client applies (Windows `CON.txt` …). */
|
|
50
|
+
const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Make a client-supplied name safe as one path component.
|
|
54
|
+
*
|
|
55
|
+
* The Client already sanitizes (`client/src/paste.js`), but the Host must not
|
|
56
|
+
* trust a browser string that becomes a file name: this strips separators,
|
|
57
|
+
* Windows-rejected characters, control characters, leading/trailing dots and
|
|
58
|
+
* spaces, reserved device names, and caps the length. An unusable name becomes
|
|
59
|
+
* `pasted-text.txt`.
|
|
60
|
+
*/
|
|
61
|
+
export function sanitizePasteName(name) {
|
|
62
|
+
let value = String(name === null || name === undefined ? '' : name)
|
|
63
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
|
|
64
|
+
.replace(/-{2,}/g, '-')
|
|
65
|
+
.replace(/^[\s.]+|[\s.]+$/g, '')
|
|
66
|
+
if (value === '') value = 'pasted-text.txt'
|
|
67
|
+
if (RESERVED_NAME.test(value)) value = '_' + value
|
|
68
|
+
if (value.length > MAX_PASTE_NAME) value = value.slice(0, MAX_PASTE_NAME)
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The composer's mention spelling for a workspace-relative path, in the host's
|
|
74
|
+
* own grammar (`formatFileMention` in ui-conversation): a path with whitespace
|
|
75
|
+
* is quoted, a quote/control character makes the token unrepresentable.
|
|
76
|
+
*
|
|
77
|
+
* @returns the `@path` / `@"path"` token, or null when the grammar cannot carry it.
|
|
78
|
+
*/
|
|
79
|
+
export function formatPasteMention(relativePath) {
|
|
80
|
+
const normalized = String(relativePath === null || relativePath === undefined ? '' : relativePath)
|
|
81
|
+
.replace(/\\/g, '/')
|
|
82
|
+
if (normalized === '') return null
|
|
83
|
+
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(normalized)) return null
|
|
84
|
+
return /\s/u.test(normalized) ? '@"' + normalized + '"' : '@' + normalized
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write one paste under `<cwd>/.dsh-paste/`, never overwriting an existing file.
|
|
89
|
+
*
|
|
90
|
+
* @param cwd - absolute session workspace root.
|
|
91
|
+
* @param name - requested file name (sanitized here).
|
|
92
|
+
* @param text - the pasted plain text, written as UTF-8.
|
|
93
|
+
* @returns `{ fileName, relativePath }` with `/` separators.
|
|
94
|
+
* @throws when the directory cannot be created or every candidate name exists.
|
|
95
|
+
*/
|
|
96
|
+
export async function writePasteFile({ cwd, name, text }) {
|
|
97
|
+
if (typeof cwd !== 'string' || cwd === '' || !path.isAbsolute(cwd)) {
|
|
98
|
+
throw new Error('paste: the session workspace is not an absolute path')
|
|
99
|
+
}
|
|
100
|
+
const directory = path.join(cwd, PASTE_DIR)
|
|
101
|
+
await mkdir(directory, { recursive: true })
|
|
102
|
+
|
|
103
|
+
const safe = sanitizePasteName(name)
|
|
104
|
+
const extension = path.extname(safe)
|
|
105
|
+
const stem = extension === '' ? safe : safe.slice(0, safe.length - extension.length)
|
|
106
|
+
|
|
107
|
+
for (let attempt = 1; attempt <= 100; attempt += 1) {
|
|
108
|
+
const fileName = attempt === 1 ? safe : stem + '-' + attempt + extension
|
|
109
|
+
const target = path.join(directory, fileName)
|
|
110
|
+
let handle
|
|
111
|
+
try {
|
|
112
|
+
handle = await open(target, 'wx')
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error && error.code === 'EEXIST') continue
|
|
115
|
+
throw error
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await handle.writeFile(text, 'utf8')
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// A half-written file is worse than no file: the name would be taken by
|
|
121
|
+
// an empty scratch the user cannot distinguish from a real paste.
|
|
122
|
+
await handle.close().catch(() => {})
|
|
123
|
+
await unlink(target).catch(() => {})
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
await handle.close()
|
|
127
|
+
return { fileName, relativePath: PASTE_DIR + '/' + fileName }
|
|
128
|
+
}
|
|
129
|
+
throw new Error('paste: every candidate file name is taken')
|
|
130
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Claude Design System Theme",
|
|
4
|
+
"description": "Re-maps the interface onto CDS: warm-gray surfaces, clay brand accent, dual-ring focus and organic motion, with component-level refinement of the composer, sidebar, message rows and code blocks."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cds-theme",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DeepSeek Harness 的 Claude Design System 主题插件:把宿主的 --dsw-* 令牌整体重映射到 CDS 的暖灰表面阶梯、陶土橙品牌色、双环焦点与有机动效,并对输入框、侧栏、消息行、代码块做组件级细化。明暗两套配色,配色/圆角/阴影走 ctx.theme.overrideTokens,组件细化走插件自有样式表。 / Claude Design System theme for DeepSeek Harness: remaps the host's --dsw-* tokens onto CDS warm-gray surfaces, clay brand accent, dual-ring focus and organic motion, then refines the composer, sidebar, message rows and code blocks at the component level.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"icon": "./icon.svg",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "HwFee <1744567281@qq.com>",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"registry": "https://registry.npmjs.org"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"deepseek",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"theme",
|
|
23
|
+
"claude",
|
|
24
|
+
"design-system",
|
|
25
|
+
"cds",
|
|
26
|
+
"appearance"
|
|
27
|
+
],
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
},
|
|
32
|
+
"client": {
|
|
33
|
+
"inject": [
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-settings-general"
|
|
36
|
+
],
|
|
37
|
+
"platform": "web",
|
|
38
|
+
"immediately": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"default": "./lib/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./client": "./client/client.js",
|
|
46
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
47
|
+
"./locale/*.json": "./locale/*.json",
|
|
48
|
+
"./icon": "./icon.svg",
|
|
49
|
+
"./package.json": "./package.json"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"lib",
|
|
53
|
+
"client/client.js",
|
|
54
|
+
"locale",
|
|
55
|
+
"assets",
|
|
56
|
+
"cordis.patch.yml",
|
|
57
|
+
"icon.svg",
|
|
58
|
+
"README.md"
|
|
59
|
+
],
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "node tools/build-client.mjs",
|
|
62
|
+
"build:check": "node tools/build-client.mjs --check",
|
|
63
|
+
"test": "node tools/build-client.mjs --check && node test/tokens.test.mjs && node test/css.test.mjs && node test/bridge.test.mjs && node test/fonts.test.mjs && node test/typography.test.mjs && node test/hero.test.mjs && node test/markdown.test.mjs && node test/locale.test.mjs && node test/section.test.mjs && node test/thememode.test.mjs && node test/navicon.test.mjs && node test/host.test.mjs && node test/styles.test.mjs && node test/master-switch.test.mjs && node test/paste.test.mjs && node test/new-session-layout.test.mjs && node test/prefer-cjk.test.mjs && node test/ripple.test.mjs",
|
|
64
|
+
"verify": "node tools/verify.mjs"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"esbuild": "0.25.10"
|
|
68
|
+
}
|
|
69
|
+
}
|