dsh-folder-path 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/README_EN.md +179 -0
- package/client/index.js +306 -0
- package/cordis.patch.yml +21 -0
- package/docs/DETAIL.md +10 -0
- package/docs/DETAIL.zh.md +10 -0
- package/icon.svg +1 -0
- package/lib/client.js +319 -0
- package/lib/index.js +457 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +73 -4
- package/scripts/build-client.mjs +45 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-folder-path` — host half.
|
|
3
|
+
*
|
|
4
|
+
* A folder copied in the desktop file manager cannot travel through the
|
|
5
|
+
* browser: the paste event carries a `File` with no readable bytes, so
|
|
6
|
+
* `dsh-client-file-upload` fails and the composer shows 上传失败. The browser
|
|
7
|
+
* also never sees the folder's location — `File.name` is the basename and the
|
|
8
|
+
* path is not part of the paste payload at all.
|
|
9
|
+
*
|
|
10
|
+
* This half answers the one question the browser cannot: *which paths did the
|
|
11
|
+
* user just copy?* The clipboard is process-wide on the machine running the
|
|
12
|
+
* GUI, and at paste time it holds exactly the payload being pasted, so reading
|
|
13
|
+
* the platform clipboard's file list recovers the real full paths:
|
|
14
|
+
*
|
|
15
|
+
* * Windows — the CF_HDROP file-drop list, through PowerShell's
|
|
16
|
+
* `System.Windows.Forms.Clipboard`;
|
|
17
|
+
* * macOS — the NSPasteboard file URLs (plus the legacy
|
|
18
|
+
* `NSFilenamesPboardType` list), through `osascript -l JavaScript`.
|
|
19
|
+
*
|
|
20
|
+
* Scope is deliberately narrow:
|
|
21
|
+
*
|
|
22
|
+
* * one exact POST route, no agent-visible surface, no model content;
|
|
23
|
+
* * the route only *reads* the clipboard and matches directory basenames the
|
|
24
|
+
* browser claimed to be pasting — a mismatched clipboard yields no answer
|
|
25
|
+
* instead of a wrong path;
|
|
26
|
+
* * other hosts answer `unsupported-platform` and the browser half
|
|
27
|
+
* degrades to a notice.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-folder-path
|
|
30
|
+
*/
|
|
31
|
+
import { execFile } from 'node:child_process'
|
|
32
|
+
import { stat } from 'node:fs/promises'
|
|
33
|
+
import { basename } from 'node:path'
|
|
34
|
+
|
|
35
|
+
/** Cordis plugin name; the profile row's `id` is independent of it. */
|
|
36
|
+
export const name = 'folder-path'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The single endpoint the browser half calls. Hand-mirrored in
|
|
40
|
+
* `client/index.js`: the two halves ship as one package but cannot import each
|
|
41
|
+
* other (the browser bundle is a hand-written classic script).
|
|
42
|
+
*/
|
|
43
|
+
export const ROUTE_PATH = '/dsh-folder-path/api/v1/clipboard-paths'
|
|
44
|
+
|
|
45
|
+
/** Clipboard probes are interactive: a wedged child process must not hold a paste. */
|
|
46
|
+
const DEFAULT_TIMEOUT_MS = 5000
|
|
47
|
+
|
|
48
|
+
/** A paste names a handful of folders; anything larger is not this request. */
|
|
49
|
+
const MAX_REQUEST_BYTES = 64 * 1024
|
|
50
|
+
|
|
51
|
+
/** Hosts whose clipboard can answer this route. */
|
|
52
|
+
const DESKTOP_PLATFORMS = new Set(['win32', 'darwin'])
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Windows PowerShell probe. Emits base64(UTF-8 JSON) on stdout so a Chinese path
|
|
56
|
+
* can never be mangled by the console code page of a redirected Windows
|
|
57
|
+
* PowerShell process, which is what plain `ConvertTo-Json` output would hit.
|
|
58
|
+
*
|
|
59
|
+
* `-InputObject` keeps a single-element result a JSON array (`items` is always
|
|
60
|
+
* a list), and `[char]92`/`[char]47` avoid backslash quoting hazards.
|
|
61
|
+
*/
|
|
62
|
+
const WINDOWS_PROBE_SCRIPT = [
|
|
63
|
+
"$ErrorActionPreference = 'Stop'",
|
|
64
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
65
|
+
'$items = @()',
|
|
66
|
+
'foreach ($raw in [System.Windows.Forms.Clipboard]::GetFileDropList()) {',
|
|
67
|
+
' $full = [string]$raw',
|
|
68
|
+
' if ([string]::IsNullOrWhiteSpace($full)) { continue }',
|
|
69
|
+
' $trimmed = $full.TrimEnd([char]92, [char]47)',
|
|
70
|
+
' $name = if ($trimmed.Length -gt 0) { [System.IO.Path]::GetFileName($trimmed) } else { $full }',
|
|
71
|
+
' $items += [pscustomobject]@{',
|
|
72
|
+
' path = $full',
|
|
73
|
+
' name = $name',
|
|
74
|
+
' isDirectory = [System.IO.Directory]::Exists($full)',
|
|
75
|
+
' }',
|
|
76
|
+
'}',
|
|
77
|
+
'$payload = [pscustomobject]@{ ok = $true; items = @($items) }',
|
|
78
|
+
'$json = ConvertTo-Json -InputObject $payload -Compress -Depth 4',
|
|
79
|
+
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json))',
|
|
80
|
+
].join('\n')
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* macOS JXA probe. Emits a JSON array of absolute paths on stdout.
|
|
84
|
+
*
|
|
85
|
+
* Two sources are merged because Finder and other apps do not agree on one:
|
|
86
|
+
*
|
|
87
|
+
* * `readObjectsForClassesOptions` returns every pasteboard item that carries
|
|
88
|
+
* a `public.file-url` (the modern shape, one item per copied file);
|
|
89
|
+
* * `NSFilenamesPboardType` is Finder's legacy path list, which still carries
|
|
90
|
+
* a multi-selection that the modern representation may reduce to one item.
|
|
91
|
+
*
|
|
92
|
+
* Non-file URLs (a copied web link) are dropped, and duplicates keep first
|
|
93
|
+
* position, so the answer is the clipboard's own order. Directory versus file
|
|
94
|
+
* is decided by the host process, not here: AppleScript can stat, but a child
|
|
95
|
+
* process that reports the path list is the smaller surface.
|
|
96
|
+
*/
|
|
97
|
+
const DARWIN_PROBE_SCRIPT = [
|
|
98
|
+
"ObjC.import('AppKit')",
|
|
99
|
+
'var pasteboard = $.NSPasteboard.generalPasteboard',
|
|
100
|
+
'var paths = []',
|
|
101
|
+
'function remember(value) {',
|
|
102
|
+
" if (typeof value !== 'string' || value.length === 0) return",
|
|
103
|
+
' if (paths.indexOf(value) >= 0) return',
|
|
104
|
+
' paths.push(value)',
|
|
105
|
+
'}',
|
|
106
|
+
'var urls = pasteboard.readObjectsForClassesOptions($.NSArray.arrayWithObject($.NSURL), $.NSDictionary.dictionary)',
|
|
107
|
+
'if (urls && urls.count > 0) {',
|
|
108
|
+
' for (var index = 0; index < urls.count; index += 1) {',
|
|
109
|
+
' var url = urls.objectAtIndex(index)',
|
|
110
|
+
' if (url.isFileURL === true) remember(ObjC.unwrap(url.path))',
|
|
111
|
+
' }',
|
|
112
|
+
'}',
|
|
113
|
+
'try {',
|
|
114
|
+
" var legacy = pasteboard.propertyListForType('NSFilenamesPboardType')",
|
|
115
|
+
' if (legacy && legacy.count > 0) {',
|
|
116
|
+
' for (var other = 0; other < legacy.count; other += 1) remember(ObjC.unwrap(legacy.objectAtIndex(other)))',
|
|
117
|
+
' }',
|
|
118
|
+
'} catch (error) {',
|
|
119
|
+
' /* a pasteboard without the legacy flavour simply has nothing to add */',
|
|
120
|
+
'}',
|
|
121
|
+
'JSON.stringify(paths)',
|
|
122
|
+
].join('\n')
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Install the clipboard route.
|
|
126
|
+
*
|
|
127
|
+
* `webServer` is acquired through `ctx.inject` rather than read directly: the
|
|
128
|
+
* service appears asynchronously, and a plain read inside `apply` would silently
|
|
129
|
+
* see nothing and never register the route.
|
|
130
|
+
*
|
|
131
|
+
* @param ctx - host plugin context.
|
|
132
|
+
* @param config - the profile row's `config` block.
|
|
133
|
+
* @param config.enabled - set `false` to mount the host half inert.
|
|
134
|
+
* @param config.timeoutMs - clipboard probe budget in milliseconds.
|
|
135
|
+
* @param config.powershell - PowerShell executable used for the Windows probe.
|
|
136
|
+
* @param config.osascript - `osascript` executable used for the macOS probe.
|
|
137
|
+
* @param config.allowNonLoopback - allow a browser on another machine to read this host's clipboard.
|
|
138
|
+
* @returns nothing; the route is registered once `webServer` appears.
|
|
139
|
+
*/
|
|
140
|
+
export function apply(ctx, config = {}) {
|
|
141
|
+
if (config?.enabled === false) return
|
|
142
|
+
const probe = {
|
|
143
|
+
timeoutMs: Number(config?.timeoutMs) > 0 ? Number(config.timeoutMs) : DEFAULT_TIMEOUT_MS,
|
|
144
|
+
powershell:
|
|
145
|
+
firstString(config?.powershell) || firstString(process.env.DSH_FOLDER_PATH_POWERSHELL) || 'powershell.exe',
|
|
146
|
+
osascript:
|
|
147
|
+
firstString(config?.osascript) || firstString(process.env.DSH_FOLDER_PATH_OSASCRIPT) || '/usr/bin/osascript',
|
|
148
|
+
}
|
|
149
|
+
const allowNonLoopback = config?.allowNonLoopback === true
|
|
150
|
+
|
|
151
|
+
ctx.inject(['webServer'], (host) => {
|
|
152
|
+
host.effect(
|
|
153
|
+
() =>
|
|
154
|
+
host.webServer.register({
|
|
155
|
+
kind: 'exact',
|
|
156
|
+
path: ROUTE_PATH,
|
|
157
|
+
handler: createHandler(ctx, probe, allowNonLoopback),
|
|
158
|
+
}),
|
|
159
|
+
'dsh-folder-path: clipboard route',
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the request handler.
|
|
166
|
+
* @param ctx - host plugin context, for warnings.
|
|
167
|
+
* @param probe - the probe's budget and executable overrides.
|
|
168
|
+
* @param allowNonLoopback - whether a non-loopback peer may read the clipboard.
|
|
169
|
+
* @returns the exact-route handler.
|
|
170
|
+
*/
|
|
171
|
+
function createHandler(ctx, probe, allowNonLoopback) {
|
|
172
|
+
return async (request, response) => {
|
|
173
|
+
if (request.method !== 'POST') {
|
|
174
|
+
sendJson(response, 405, { ok: false, reason: 'method-not-allowed' }, { allow: 'POST' })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (!sameOrigin(request)) {
|
|
178
|
+
sendJson(response, 403, { ok: false, reason: 'cross-origin' })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (!allowNonLoopback && !isLoopback(request)) {
|
|
182
|
+
// The clipboard belongs to the machine running the host; a browser on
|
|
183
|
+
// another machine would be handed this machine's paths instead of its own.
|
|
184
|
+
sendJson(response, 403, { ok: false, reason: 'remote-not-supported', paths: [] })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let names
|
|
189
|
+
try {
|
|
190
|
+
const body = await readJsonBody(request)
|
|
191
|
+
names = Array.isArray(body?.names)
|
|
192
|
+
? body.names.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
193
|
+
: []
|
|
194
|
+
} catch (error) {
|
|
195
|
+
sendJson(response, 400, { ok: false, reason: 'bad-request', message: messageOf(error) })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!DESKTOP_PLATFORMS.has(process.platform)) {
|
|
200
|
+
sendJson(response, 200, { ok: false, reason: 'unsupported-platform', platform: process.platform, paths: [] })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let entries
|
|
205
|
+
try {
|
|
206
|
+
entries = await readClipboardEntries(probe)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
ctx.logger?.warn?.('dsh-folder-path: clipboard probe failed: %o', error)
|
|
209
|
+
sendJson(response, 200, { ok: false, reason: 'clipboard-error', message: messageOf(error), paths: [] })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const directories = entries.filter((entry) => entry.isDirectory === true)
|
|
214
|
+
const matches = matchDirectories(directories, names)
|
|
215
|
+
if (names.length > 0 && matches.length === 0) {
|
|
216
|
+
// The browser pasted something the clipboard no longer describes (or the
|
|
217
|
+
// name it reports differs from the on-disk name). Answering "no match" is
|
|
218
|
+
// the point: a plausible-looking wrong path would be worse than none.
|
|
219
|
+
sendJson(response, 200, {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: 'no-match',
|
|
222
|
+
paths: [],
|
|
223
|
+
clipboard: { directories: directories.length, total: entries.length },
|
|
224
|
+
})
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
sendJson(response, 200, { ok: true, paths: matches.map((entry) => entry.path), entries: matches })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Filter clipboard directories down to the ones the browser is pasting.
|
|
234
|
+
*
|
|
235
|
+
* An empty `names` list means the caller wants every copied directory; a
|
|
236
|
+
* non-empty list is matched by basename, case-insensitively, because that is
|
|
237
|
+
* all the browser can see of the path.
|
|
238
|
+
*
|
|
239
|
+
* @param directories - directory entries from the clipboard.
|
|
240
|
+
* @param names - basenames the browser reported.
|
|
241
|
+
* @returns the matching entries, in clipboard order.
|
|
242
|
+
* @remarks Exported for the host test suite; the route is its only runtime caller.
|
|
243
|
+
*/
|
|
244
|
+
export function matchDirectories(directories, names) {
|
|
245
|
+
if (names.length === 0) return directories
|
|
246
|
+
const wanted = new Set(names.map((entry) => entry.toLowerCase()))
|
|
247
|
+
return directories.filter((entry) => wanted.has(entry.name.toLowerCase()))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse the macOS probe's stdout into absolute paths.
|
|
252
|
+
* @param stdout - the probe's stdout.
|
|
253
|
+
* @returns the paths it reported, in clipboard order.
|
|
254
|
+
* @remarks Exported for the host test suite; the probe is its only runtime caller.
|
|
255
|
+
*/
|
|
256
|
+
export function parseProbePaths(stdout) {
|
|
257
|
+
const text = String(stdout ?? '').trim()
|
|
258
|
+
if (text === '') return []
|
|
259
|
+
let value
|
|
260
|
+
try {
|
|
261
|
+
value = JSON.parse(text)
|
|
262
|
+
} catch (cause) {
|
|
263
|
+
throw new Error('clipboard probe returned unreadable output', { cause })
|
|
264
|
+
}
|
|
265
|
+
if (!Array.isArray(value)) throw new Error('clipboard probe returned an unexpected shape')
|
|
266
|
+
return value.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The probe in flight, if any: several pastes must not fork a child process each. */
|
|
270
|
+
let inflight = null
|
|
271
|
+
let inflightKey = null
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read the platform clipboard's file list.
|
|
275
|
+
* @param probe - the probe's budget and executable overrides.
|
|
276
|
+
* @returns the clipboard items, each `{ path, name, isDirectory }`.
|
|
277
|
+
*/
|
|
278
|
+
function readClipboardEntries(probe) {
|
|
279
|
+
const executable = process.platform === 'darwin' ? probe.osascript : probe.powershell
|
|
280
|
+
const key = `${process.platform}\u0000${executable}\u0000${String(probe.timeoutMs)}`
|
|
281
|
+
if (inflight !== null && inflightKey === key) return inflight
|
|
282
|
+
|
|
283
|
+
const run = process.platform === 'darwin' ? probeMacClipboard(probe) : probeWindowsClipboard(probe)
|
|
284
|
+
const pending = run.finally(() => {
|
|
285
|
+
if (inflight === pending) {
|
|
286
|
+
inflight = null
|
|
287
|
+
inflightKey = null
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
inflight = pending
|
|
291
|
+
inflightKey = key
|
|
292
|
+
return pending
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Run one probe executable and return its stdout.
|
|
297
|
+
* @param executable - the executable to spawn.
|
|
298
|
+
* @param args - its arguments.
|
|
299
|
+
* @param timeoutMs - how long it may take.
|
|
300
|
+
* @returns its stdout.
|
|
301
|
+
*/
|
|
302
|
+
function runProbe(executable, args, timeoutMs) {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
execFile(
|
|
305
|
+
executable,
|
|
306
|
+
args,
|
|
307
|
+
{ windowsHide: true, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
|
|
308
|
+
(error, stdout, stderr) => {
|
|
309
|
+
if (error) {
|
|
310
|
+
reject(new Error(`clipboard probe failed: ${firstLine(stderr) || messageOf(error)}`, { cause: error }))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
resolve(String(stdout))
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Read the Windows clipboard file-drop list.
|
|
321
|
+
* @param probe - the probe's budget and executable overrides.
|
|
322
|
+
* @returns the clipboard items, each `{ path, name, isDirectory }`.
|
|
323
|
+
*/
|
|
324
|
+
async function probeWindowsClipboard(probe) {
|
|
325
|
+
const stdout = await runProbe(
|
|
326
|
+
probe.powershell,
|
|
327
|
+
['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROBE_SCRIPT],
|
|
328
|
+
probe.timeoutMs,
|
|
329
|
+
)
|
|
330
|
+
let payload
|
|
331
|
+
try {
|
|
332
|
+
payload = JSON.parse(Buffer.from(stdout.trim(), 'base64').toString('utf8'))
|
|
333
|
+
} catch (cause) {
|
|
334
|
+
throw new Error('clipboard probe returned unreadable output', { cause })
|
|
335
|
+
}
|
|
336
|
+
return Array.isArray(payload?.items) ? payload.items : []
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Read the macOS clipboard's file URLs and describe them on disk.
|
|
341
|
+
*
|
|
342
|
+
* Directory versus file is decided here with `stat`: the probe reports paths,
|
|
343
|
+
* and a path that no longer exists is simply not a directory.
|
|
344
|
+
*
|
|
345
|
+
* @param probe - the probe's budget and executable overrides.
|
|
346
|
+
* @returns the clipboard items, each `{ path, name, isDirectory }`.
|
|
347
|
+
*/
|
|
348
|
+
async function probeMacClipboard(probe) {
|
|
349
|
+
const stdout = await runProbe(probe.osascript, ['-l', 'JavaScript', '-e', DARWIN_PROBE_SCRIPT], probe.timeoutMs)
|
|
350
|
+
const paths = parseProbePaths(stdout)
|
|
351
|
+
const entries = []
|
|
352
|
+
for (const path of paths) {
|
|
353
|
+
let isDirectory = false
|
|
354
|
+
try {
|
|
355
|
+
isDirectory = (await stat(path)).isDirectory()
|
|
356
|
+
} catch {
|
|
357
|
+
isDirectory = false
|
|
358
|
+
}
|
|
359
|
+
entries.push({ path, name: basename(path), isDirectory })
|
|
360
|
+
}
|
|
361
|
+
return entries
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Read a JSON request body.
|
|
366
|
+
* @param request - the incoming request.
|
|
367
|
+
* @returns the parsed body, or `{}` for an empty one.
|
|
368
|
+
*/
|
|
369
|
+
async function readJsonBody(request) {
|
|
370
|
+
const chunks = []
|
|
371
|
+
let size = 0
|
|
372
|
+
for await (const chunk of request) {
|
|
373
|
+
size += chunk.length
|
|
374
|
+
if (size > MAX_REQUEST_BYTES) throw new Error('request body too large')
|
|
375
|
+
chunks.push(chunk)
|
|
376
|
+
}
|
|
377
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
378
|
+
return text === '' ? {} : JSON.parse(text)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Whether a request came from the page this host is serving.
|
|
383
|
+
*
|
|
384
|
+
* Browsers send `Origin` on cross-origin POSTs *and* same-origin ones, so an
|
|
385
|
+
* absent header (curl, tests) passes and a present one must match `Host`.
|
|
386
|
+
*
|
|
387
|
+
* @param request - the incoming request.
|
|
388
|
+
* @returns true when the request may read the clipboard.
|
|
389
|
+
*/
|
|
390
|
+
function sameOrigin(request) {
|
|
391
|
+
const origin = request.headers.origin
|
|
392
|
+
if (origin === undefined) return true
|
|
393
|
+
try {
|
|
394
|
+
return new URL(origin).host === request.headers.host
|
|
395
|
+
} catch {
|
|
396
|
+
return false
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether a request arrived over the loopback interface.
|
|
402
|
+
* @param request - the incoming request.
|
|
403
|
+
* @returns true for a loopback peer, including IPv4-mapped forms.
|
|
404
|
+
*/
|
|
405
|
+
function isLoopback(request) {
|
|
406
|
+
const address = request.socket?.remoteAddress ?? ''
|
|
407
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Write a JSON response.
|
|
412
|
+
* @param response - the response to own.
|
|
413
|
+
* @param status - HTTP status code.
|
|
414
|
+
* @param value - JSON-serializable payload.
|
|
415
|
+
* @param headers - extra headers.
|
|
416
|
+
*/
|
|
417
|
+
function sendJson(response, status, value, headers = {}) {
|
|
418
|
+
const body = JSON.stringify(value)
|
|
419
|
+
response.writeHead(status, {
|
|
420
|
+
'content-type': 'application/json; charset=utf-8',
|
|
421
|
+
'content-length': Buffer.byteLength(body),
|
|
422
|
+
'cache-control': 'no-store',
|
|
423
|
+
...headers,
|
|
424
|
+
})
|
|
425
|
+
response.end(body)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* First non-empty string of a value, for optional config knobs.
|
|
430
|
+
* @param value - the configured value.
|
|
431
|
+
* @returns the trimmed string, or an empty string.
|
|
432
|
+
*/
|
|
433
|
+
function firstString(value) {
|
|
434
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : ''
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* First non-empty line of a stream, for a compact error message.
|
|
439
|
+
* @param text - raw stderr text.
|
|
440
|
+
* @returns the first non-empty line, trimmed.
|
|
441
|
+
*/
|
|
442
|
+
function firstLine(text) {
|
|
443
|
+
const line = String(text ?? '')
|
|
444
|
+
.split('\n')
|
|
445
|
+
.map((entry) => entry.trim())
|
|
446
|
+
.find((entry) => entry.length > 0)
|
|
447
|
+
return line ?? ''
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Human-readable message for an unknown thrown value.
|
|
452
|
+
* @param error - the caught value.
|
|
453
|
+
* @returns the message.
|
|
454
|
+
*/
|
|
455
|
+
function messageOf(error) {
|
|
456
|
+
return error instanceof Error ? error.message : String(error)
|
|
457
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-folder-path",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH 插件:粘贴(复制来的)文件夹时不再尝试上传,而是把它的完整路径作为文本插入输入框(Windows / macOS)。Paste a copied folder and get its full path as text instead of a failed upload (Windows and macOS).",
|
|
5
|
+
"private": false,
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"default": "./lib/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"default": "./lib/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib/index.js",
|
|
22
|
+
"lib/client.js",
|
|
23
|
+
"client",
|
|
24
|
+
"scripts",
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"README.md",
|
|
27
|
+
"README_EN.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"icon.svg",
|
|
30
|
+
"locale/*.json",
|
|
31
|
+
"docs"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "node scripts/build-client.mjs",
|
|
35
|
+
"prepare": "node scripts/build-client.mjs",
|
|
36
|
+
"test": "node --check lib/index.js && node --check lib/client.js && node --test"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=24",
|
|
40
|
+
"dsh": "^0.2.1-rc.2 || ^0.2.2-alpha.2"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"dsh",
|
|
44
|
+
"deepseek-harness",
|
|
45
|
+
"dsh-plugin",
|
|
46
|
+
"client-plugin",
|
|
47
|
+
"cordis-plugin",
|
|
48
|
+
"paste",
|
|
49
|
+
"folder",
|
|
50
|
+
"path",
|
|
51
|
+
"clipboard",
|
|
52
|
+
"macos",
|
|
53
|
+
"windows"
|
|
54
|
+
],
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"icon": "./icon.svg",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/CooperZhuang/dsh-plugins-cooper-custom.git",
|
|
60
|
+
"directory": "packages/dsh-folder-path"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://github.com/CooperZhuang/dsh-plugins-cooper-custom/tree/main/packages/dsh-folder-path#readme",
|
|
63
|
+
"bugs": {
|
|
64
|
+
"url": "https://github.com/CooperZhuang/dsh-plugins-cooper-custom/issues"
|
|
65
|
+
},
|
|
66
|
+
"dsh": {
|
|
67
|
+
"manifestVersion": 1,
|
|
68
|
+
"bundle": {
|
|
69
|
+
"patch": "./cordis.patch.yml"
|
|
70
|
+
},
|
|
71
|
+
"client": {
|
|
72
|
+
"platform": "web"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap the hand-written browser half into the DSH client bundle contract.
|
|
3
|
+
*
|
|
4
|
+
* The client module system executes a plugin bundle as a plain classic script
|
|
5
|
+
* that REGISTERS a lazy CJS factory:
|
|
6
|
+
*
|
|
7
|
+
* window.__ModuleLoader__.load({ id, factory: (require) => exports })
|
|
8
|
+
*
|
|
9
|
+
* so the bundle needs no bundler. `client/index.js` defines `createPlugin`; this
|
|
10
|
+
* script wraps it and writes `lib/client.js`, which is what `exports['./client']`
|
|
11
|
+
* points at. Run by `npm run build` (and by `prepare`, so a `dsh plugin add`
|
|
12
|
+
* always ships a bundle).
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join, resolve } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
19
|
+
const source = readFileSync(join(root, 'client', 'index.js'), 'utf8')
|
|
20
|
+
const target = join(root, 'lib', 'client.js')
|
|
21
|
+
|
|
22
|
+
const indent = (text) =>
|
|
23
|
+
text
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => (line.length > 0 ? ` ${line}` : line))
|
|
26
|
+
.join('\n')
|
|
27
|
+
|
|
28
|
+
const bundle = `window.__ModuleLoader__.load({
|
|
29
|
+
id: "dsh-folder-path",
|
|
30
|
+
factory: (require) => {
|
|
31
|
+
var module = { exports: {} };
|
|
32
|
+
var exports = module.exports;
|
|
33
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
34
|
+
// ---- client/index.js ----
|
|
35
|
+
${indent(source)}
|
|
36
|
+
const plugin = createPlugin(require);
|
|
37
|
+
exports.apply = plugin.apply;
|
|
38
|
+
return module.exports;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
44
|
+
writeFileSync(target, bundle)
|
|
45
|
+
console.log(`client bundle -> ${target} (${bundle.length} bytes)`)
|