dsh-plugin-look-up 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 huanglianqi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-look-up
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ macOS-style **Look Up** inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) chat: select a word in any conversation, click the small **Look Up / 查词** chip, and read the definition in a card anchored to the selection.
4
+
5
+ The definitions come from the **dictionaries you already have in Dictionary.app** — the same local, offline data macOS Look Up reads. No network request, no API key, no accessibility permission.
6
+
7
+ ![version](https://img.shields.io/npm/v/dsh-plugin-look-up) ![license](https://img.shields.io/npm/l/dsh-plugin-look-up)
8
+
9
+ ## What it looks like
10
+
11
+ ```
12
+ 选中 “serendipity” ──► [ 🔍 查词 ] ──► ┌─────────────────────────────┐
13
+ │ serendipity ✕ │
14
+ │ ˌsɛr(ə)nˈdɪpɪti │
15
+ │ noun formal │
16
+ │ ① uncountable (luck) … │
17
+ │ ② countable … │
18
+ ├─────────────────────────────┤
19
+ │ macOS 系统词典 · 本地查询 │
20
+ │ 复制 在词典中打开 │
21
+ └─────────────────────────────┘
22
+ ```
23
+
24
+ ## Requirements
25
+
26
+ - macOS 11 or newer.
27
+ - DSH Desktop (or the DSH Web GUI served on the same Mac).
28
+ - At least one dictionary installed in **Dictionary.app** (Oxford, 现代汉语词典, 新华字典, 牛津英汉汉英词典 … all work). Check with Dictionary.app → Settings → Dictionaries.
29
+
30
+ ## Install
31
+
32
+ From the DSH plugin CLI:
33
+
34
+ ```bash
35
+ dsh plugin --profile <profile> add dsh-plugin-look-up
36
+ ```
37
+
38
+ Or install the bundle manually in DSH: **Settings → Plugins → add** and give the package name `dsh-plugin-look-up`, then enable the **Look Up** row.
39
+
40
+ The package ships a small signed universal helper (`bin/dsh-lookup-dict`, arm64 + x86_64). Nothing is compiled on install.
41
+
42
+ ## Use
43
+
44
+ 1. Select a word or a short phrase in a chat message.
45
+ 2. Click the **查词 / Look Up** chip that appears next to the selection.
46
+ 3. Read the definition; **Escape**, an outside click, or scrolling closes the card.
47
+ 4. `复制` copies the word; `在词典中打开` opens the full entry in Dictionary.app through the `dict:` URL scheme.
48
+
49
+ The card follows the DSH theme (light/dark, Dream Skin tokens) and the active UI language (中文 / English).
50
+
51
+ ## How it works
52
+
53
+ | Layer | Responsibility |
54
+ | --- | --- |
55
+ | `client.js` | Reads the current selection, places the chip/card on `shell.overlay`, renders definitions. |
56
+ | `index.js` | Host routes under `/plugin/look-up/*`: loopback-only, bounded JSON body, in-memory cache. |
57
+ | `bin/dsh-lookup-dict` | Objective-C helper calling `DCSCopyTextDefinition` from DictionaryServices — the same API behind Look Up. |
58
+
59
+ Only the selected word is passed to the helper. The helper reads local dictionary data and prints JSON. There is no telemetry, no network call, and the route rejects non-loopback hosts.
60
+
61
+ ### Why not the native Look Up panel?
62
+
63
+ The system definition panel belongs to the Electron shell: DSH runs its Host in an Electron `utilityProcess` without `webContents`, and the renderer preload exposes a fixed action whitelist, so a plugin cannot call `webContents.showDefinitionForSelection()`. A native helper can *display* the panel, but it steals keyboard focus and dismisses itself when the user clicks back into DSH — a poor fit for a chat overlay. This plugin instead reproduces the content and the interaction inside the conversation, using the same dictionaries.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ npm run build:helper # rebuild + re-sign bin/dsh-lookup-dict
69
+ npm run check # syntax-check the two entry points
70
+ ```
71
+
72
+ Source of the helper: [`native/dsh-lookup-dict.m`](native/dsh-lookup-dict.m). The ad-hoc signature is applied by the build script; no Developer ID is required for local execution.
73
+
74
+ ## 中文说明
75
+
76
+ 在 DSH 对话里选中一个单词,点击旁边出现的「查词」小胶囊,就在原位弹出释义卡片。词条来自本机「词典」App 里已安装的词典(牛津、现代汉语词典、新华字典等),与 macOS 的 Look Up 同源,**完全离线**,不联网、不需要 API key、不需要辅助功能权限。
77
+
78
+ - 系统要求:macOS 11+,DSH Desktop 或同一台 Mac 上的 DSH Web GUI。
79
+ - 安装:`dsh plugin --profile <profile> add dsh-plugin-look-up`。
80
+ - 关闭:Esc、点击卡片外、滚动页面。
81
+ - 复制 / 在词典中打开(走 `dict:` URL scheme)。
82
+
83
+ 已知边界:Electron 的系统释义面板无法被第三方插件直接调用(Host 在 utilityProcess 里,渲染层 preload 只暴露固定动作白名单),所以这里用同一个系统词典数据在对话内复刻了交互。
84
+
85
+ ## License
86
+
87
+ MIT © 2026 huanglianqi
Binary file
package/client.js ADDED
@@ -0,0 +1,658 @@
1
+ /**
2
+ * dsh-plugin-look-up — browser half.
3
+ *
4
+ * A selection pill and a definition card on `shell.overlay`. The card asks the
5
+ * Host for a macOS system-dictionary definition (the same local data Look Up
6
+ * reads); nothing leaves the machine. The plugin is inert outside macOS and
7
+ * says so instead of failing silently.
8
+ */
9
+
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-plugin-look-up',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const h = React.createElement
15
+ const { useCallback, useEffect, useRef, useState } = React
16
+
17
+ const NS = 'look-up'
18
+ const LOOKUP_URL = '/plugin/look-up/lookup'
19
+ const OPEN_URL = '/plugin/look-up/open'
20
+ const MAX_WORD_LENGTH = 64
21
+ const CARD_WIDTH = 380
22
+
23
+ /** Runtime copy; the package locale files carry only the plugin metadata. */
24
+ const STRINGS = {
25
+ en: {
26
+ lookUp: 'Look Up',
27
+ loading: 'Looking up in the system dictionary…',
28
+ noEntry: 'No system-dictionary entry for “{word}”.',
29
+ unsupported: 'System-dictionary lookup is available on macOS only.',
30
+ helperMissing: 'The dictionary helper is missing. Reinstall the plugin.',
31
+ timeout: 'The dictionary took too long. Try again.',
32
+ failed: 'Lookup failed. Try again.',
33
+ copy: 'Copy',
34
+ copied: 'Copied',
35
+ openInDictionary: 'Open in Dictionary',
36
+ source: 'macOS system dictionaries · offline',
37
+ close: 'Close',
38
+ lookUpFor: 'Look up “{word}”'
39
+ },
40
+ zh: {
41
+ lookUp: '查词',
42
+ loading: '正在查询系统词典…',
43
+ noEntry: '系统词典中没有找到“{word}”。',
44
+ unsupported: '系统词典查询仅支持 macOS。',
45
+ helperMissing: '词典组件缺失,请重新安装插件。',
46
+ timeout: '查询超时,请重试。',
47
+ failed: '查询失败,请重试。',
48
+ copy: '复制',
49
+ copied: '已复制',
50
+ openInDictionary: '在词典中打开',
51
+ source: 'macOS 系统词典 · 本地查询',
52
+ close: '关闭',
53
+ lookUpFor: '查询“{word}”'
54
+ }
55
+ }
56
+
57
+ const CSS = `
58
+ .dlu-root {
59
+ position: fixed;
60
+ inset: 0;
61
+ z-index: 2147483000;
62
+ pointer-events: none;
63
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Segoe UI", sans-serif;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+ .dlu-pill {
67
+ position: fixed;
68
+ display: inline-flex;
69
+ align-items: center;
70
+ gap: 6px;
71
+ height: 30px;
72
+ padding: 0 11px;
73
+ border-radius: 8px;
74
+ border: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, .25));
75
+ background: var(--dsw-alias-bg-overlay, Canvas);
76
+ color: var(--dsw-alias-label-primary, CanvasText);
77
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .16), 0 1px 3px rgba(0, 0, 0, .10);
78
+ backdrop-filter: blur(18px) saturate(1.3);
79
+ -webkit-backdrop-filter: blur(18px) saturate(1.3);
80
+ font-size: 12px;
81
+ font-weight: 560;
82
+ line-height: 1;
83
+ letter-spacing: .01em;
84
+ white-space: nowrap;
85
+ cursor: pointer;
86
+ pointer-events: auto;
87
+ user-select: none;
88
+ animation: dlu-in 120ms cubic-bezier(.2, .8, .3, 1);
89
+ }
90
+ .dlu-pill:hover {
91
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2, Canvas));
92
+ }
93
+ .dlu-pill:active {
94
+ background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-bg-layer-2, Canvas));
95
+ }
96
+ .dlu-pill:focus-visible {
97
+ outline: 2px solid var(--dsw-alias-brand-primary, Highlight);
98
+ outline-offset: 2px;
99
+ }
100
+ .dlu-pill svg { width: 13px; height: 13px; flex: none; opacity: .82; }
101
+
102
+ .dlu-card {
103
+ position: fixed;
104
+ display: flex;
105
+ flex-direction: column;
106
+ width: ${CARD_WIDTH}px;
107
+ max-width: calc(100vw - 24px);
108
+ overflow: hidden;
109
+ border-radius: 14px;
110
+ border: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, .25));
111
+ background: var(--dsw-alias-bg-overlay, Canvas);
112
+ color: var(--dsw-alias-label-primary, CanvasText);
113
+ box-shadow:
114
+ 0 24px 64px rgba(0, 0, 0, .24),
115
+ 0 8px 24px rgba(0, 0, 0, .14),
116
+ 0 1px 0 rgba(255, 255, 255, .04) inset;
117
+ backdrop-filter: blur(26px) saturate(1.4);
118
+ -webkit-backdrop-filter: blur(26px) saturate(1.4);
119
+ pointer-events: auto;
120
+ animation: dlu-in 140ms cubic-bezier(.2, .8, .3, 1);
121
+ }
122
+ .dlu-head {
123
+ display: flex;
124
+ align-items: flex-start;
125
+ gap: 10px;
126
+ padding: 13px 12px 9px 16px;
127
+ }
128
+ .dlu-titles { min-width: 0; }
129
+ .dlu-word {
130
+ font-size: 16px;
131
+ font-weight: 680;
132
+ line-height: 1.25;
133
+ letter-spacing: -.012em;
134
+ overflow-wrap: anywhere;
135
+ }
136
+ .dlu-phonetic {
137
+ margin-top: 3px;
138
+ font-size: 11.5px;
139
+ line-height: 1.35;
140
+ color: var(--dsw-alias-label-secondary, GrayText);
141
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
142
+ overflow-wrap: anywhere;
143
+ }
144
+ .dlu-close {
145
+ margin-left: auto;
146
+ flex: none;
147
+ display: grid;
148
+ place-items: center;
149
+ width: 26px;
150
+ height: 26px;
151
+ padding: 0;
152
+ border: 0;
153
+ border-radius: 7px;
154
+ background: transparent;
155
+ color: var(--dsw-alias-label-secondary, GrayText);
156
+ cursor: pointer;
157
+ }
158
+ .dlu-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, CanvasText); }
159
+ .dlu-close:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, Highlight); outline-offset: 1px; }
160
+ .dlu-close svg { width: 14px; height: 14px; }
161
+
162
+ .dlu-body {
163
+ padding: 1px 16px 14px;
164
+ overflow-y: auto;
165
+ overscroll-behavior: contain;
166
+ font-size: 13.5px;
167
+ line-height: 1.64;
168
+ color: var(--dsw-alias-label-primary, CanvasText);
169
+ scrollbar-width: thin;
170
+ }
171
+ .dlu-body p { margin: 0 0 9px; }
172
+ .dlu-body p:last-child { margin-bottom: 0; }
173
+ .dlu-body b, .dlu-body strong { font-weight: 640; }
174
+ .dlu-empty {
175
+ color: var(--dsw-alias-label-secondary, GrayText);
176
+ font-size: 13px;
177
+ line-height: 1.6;
178
+ }
179
+ .dlu-empty.is-error { color: var(--dsw-alias-state-error-primary, #d9534f); }
180
+ .dlu-loading { display: flex; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary, GrayText); font-size: 13px; }
181
+ .dlu-dots { display: inline-flex; gap: 3px; }
182
+ .dlu-dot {
183
+ width: 4px;
184
+ height: 4px;
185
+ border-radius: 50%;
186
+ background: var(--dsw-alias-brand-primary, #4c8dff);
187
+ animation: dlu-pulse 900ms ease-in-out infinite;
188
+ }
189
+ .dlu-dot:nth-child(2) { animation-delay: 120ms; }
190
+ .dlu-dot:nth-child(3) { animation-delay: 240ms; }
191
+
192
+ .dlu-foot {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 6px;
196
+ padding: 9px 10px 9px 14px;
197
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, .2));
198
+ background: color-mix(in srgb, var(--dsw-alias-bg-layer-2, Canvas) 55%, transparent);
199
+ }
200
+ .dlu-source {
201
+ margin-right: auto;
202
+ font-size: 10.5px;
203
+ line-height: 1.3;
204
+ color: var(--dsw-alias-label-tertiary, GrayText);
205
+ white-space: nowrap;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ }
209
+ .dlu-btn {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ gap: 4px;
213
+ height: 24px;
214
+ padding: 0 8px;
215
+ border-radius: 6px;
216
+ border: 0;
217
+ background: transparent;
218
+ color: var(--dsw-alias-label-secondary, GrayText);
219
+ font-size: 11px;
220
+ font-weight: 550;
221
+ line-height: 1;
222
+ white-space: nowrap;
223
+ cursor: pointer;
224
+ }
225
+ .dlu-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, CanvasText); }
226
+ .dlu-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, Highlight); outline-offset: 1px; }
227
+ .dlu-btn[data-copied="true"] { color: var(--dsw-alias-state-success-primary, #2e9e5b); }
228
+
229
+ @keyframes dlu-in {
230
+ from { opacity: 0; transform: translateY(3px) scale(.985); }
231
+ to { opacity: 1; transform: translateY(0) scale(1); }
232
+ }
233
+ @keyframes dlu-pulse {
234
+ 0%, 100% { opacity: .25; transform: translateY(0); }
235
+ 40% { opacity: 1; transform: translateY(-2px); }
236
+ }
237
+ @media (prefers-reduced-motion: reduce) {
238
+ .dlu-pill, .dlu-card { animation: none; }
239
+ .dlu-dot { animation: none; opacity: .7; }
240
+ }
241
+ `
242
+
243
+ const ICON_LOOKUP = h('svg', {
244
+ viewBox: '0 0 16 16',
245
+ fill: 'none',
246
+ stroke: 'currentColor',
247
+ strokeWidth: 1.6,
248
+ strokeLinecap: 'round',
249
+ 'aria-hidden': true
250
+ }, h('circle', { cx: 7, cy: 7, r: 4.2 }), h('path', { d: 'M10.2 10.2 13.5 13.5' }))
251
+
252
+ const ICON_CLOSE = h('svg', {
253
+ viewBox: '0 0 16 16',
254
+ fill: 'none',
255
+ stroke: 'currentColor',
256
+ strokeWidth: 1.7,
257
+ strokeLinecap: 'round',
258
+ 'aria-hidden': true
259
+ }, h('path', { d: 'M4 4l8 8M12 4l-8 8' }))
260
+
261
+ function clamp(value, min, max) {
262
+ return Math.min(Math.max(value, min), max)
263
+ }
264
+
265
+ /** The element carrying a DOM node, or null. */
266
+ function elementOf(node) {
267
+ if (node === null || node === undefined) return null
268
+ return node.nodeType === 1 ? node : node.parentElement ?? null
269
+ }
270
+
271
+ /** Read the current non-collapsed selection when it is worth looking up. */
272
+ function readSelection() {
273
+ const selection = window.getSelection()
274
+ if (selection === null || selection === undefined || selection.isCollapsed || selection.rangeCount === 0) return null
275
+ const word = selection.toString().replace(/\s+/gu, ' ').trim()
276
+ if (word.length === 0 || word.length > MAX_WORD_LENGTH) return null
277
+ if (!/[0-9A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF\u3040-\u30FF\u3400-\u9FFF\uAC00-\uD7AF]/u.test(word)) return null
278
+
279
+ const range = selection.getRangeAt(0)
280
+ const holder = elementOf(range.commonAncestorContainer)
281
+ if (holder === null) return null
282
+ if (holder.closest('.dlu-root, input, textarea, pre, .cm-editor, [contenteditable="true"]') !== null) return null
283
+
284
+ const rect = range.getBoundingClientRect()
285
+ if (rect.width === 0 && rect.height === 0) return null
286
+ return {
287
+ word,
288
+ rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
289
+ }
290
+ }
291
+
292
+ /** Place the small trigger above the selection, or below when there is no room. */
293
+ function pillPosition(rect) {
294
+ const width = 88
295
+ const height = 30
296
+ const left = clamp(rect.right - width / 2, 8, Math.max(8, window.innerWidth - width - 8))
297
+ const above = rect.top - height - 7
298
+ const top = above >= 8 ? above : rect.bottom + 7
299
+ return { left, top }
300
+ }
301
+
302
+ /** Place the card next to the selection, preferring the roomier side. */
303
+ function cardPosition(rect) {
304
+ const width = Math.min(CARD_WIDTH, window.innerWidth - 24)
305
+ const left = clamp(rect.left, 12, Math.max(12, window.innerWidth - width - 12))
306
+ const below = window.innerHeight - rect.bottom - 12
307
+ const above = rect.top - 12
308
+ if (below >= 240 || below >= above) {
309
+ return { left, top: rect.bottom + 10, width, maxHeight: Math.max(120, below - 104) }
310
+ }
311
+ return { left, bottom: window.innerHeight - rect.top + 10, width, maxHeight: Math.max(120, above - 104) }
312
+ }
313
+
314
+ /**
315
+ * Split a raw DictionaryServices entry into a phonetic line and readable
316
+ * paragraphs. The raw form is `headword | pronunciation | sense ...` for
317
+ * English, and `headword pinyin ①sense ...` for Chinese.
318
+ */
319
+ function parseDefinition(text, word) {
320
+ const source = String(text ?? '').trim()
321
+ if (source.length === 0) return { phonetic: '', paragraphs: [] }
322
+
323
+ let segments = source.split(/\s*\|\s*/u).map((part) => part.trim()).filter(Boolean)
324
+ let phonetic = ''
325
+ let bodySegments = [source]
326
+ if (segments.length > 1 && (/[ˈˌəʃʒθðŋæɑɔɪʊɛɜːɒʌ]/u.test(segments[1]) || /^(?:BrE|AmE|US|UK)\b/u.test(segments[1]))) {
327
+ phonetic = segments[1]
328
+ bodySegments = segments.slice(2)
329
+ } else if (segments.length > 1 && /^(?:noun|verb|adjective|adverb|pronoun|preposition|conjunction|determiner|exclamation|interjection|numeral|auxiliary|phrasal verb)\b/iu.test(segments[1])) {
330
+ bodySegments = segments.slice(1)
331
+ }
332
+ if (bodySegments.length === 0) bodySegments = [source]
333
+
334
+ // Chinese entries use "|" between example phrases, not between the
335
+ // headword and the senses, so only the phonetic/POS split above applies.
336
+ let body = bodySegments.join('\n\n').trim()
337
+ if (body.toLowerCase().startsWith(word.toLowerCase())) body = body.slice(word.length).trim()
338
+ if (body.length === 0) body = source
339
+
340
+ body = body
341
+ .replace(/\s*([①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳])/gu, '\n$1')
342
+ .replace(/\n{3,}/gu, '\n\n')
343
+ .trim()
344
+
345
+ const paragraphs = body.split(/\n+/u).map((part) => part.trim()).filter(Boolean)
346
+ return { phonetic, paragraphs: paragraphs.length > 0 ? paragraphs : [body] }
347
+ }
348
+
349
+ /** Re-render whenever the locale dictionary or active locale changes. */
350
+ function useLocaleVersion(locale) {
351
+ const [, setVersion] = useState(0)
352
+ useEffect(() => locale.subscribe(() => setVersion((value) => value + 1)), [locale])
353
+ }
354
+
355
+ function LookUpLayer({ locale }) {
356
+ useLocaleVersion(locale)
357
+ const t = locale.bind(NS)
358
+
359
+ const [pill, setPill] = useState(null)
360
+ const [card, setCard] = useState(null)
361
+ const [copied, setCopied] = useState(false)
362
+ const [opening, setOpening] = useState(false)
363
+ const cardRef = useRef(null)
364
+ const requestRef = useRef(0)
365
+
366
+ useEffect(() => {
367
+ cardRef.current = card
368
+ }, [card])
369
+
370
+ useEffect(() => {
371
+ setCopied(false)
372
+ setOpening(false)
373
+ }, [card ? card.requestId : 0])
374
+
375
+ const closeAll = useCallback(() => {
376
+ setPill(null)
377
+ setCard(null)
378
+ }, [])
379
+
380
+ const openCard = useCallback((selection) => {
381
+ setPill(null)
382
+ requestRef.current += 1
383
+ const requestId = requestRef.current
384
+ setCard({
385
+ word: selection.word,
386
+ requestId,
387
+ status: 'loading',
388
+ result: null,
389
+ code: null,
390
+ position: cardPosition(selection.rect)
391
+ })
392
+ }, [])
393
+
394
+ // Ask the Host once per opened card.
395
+ useEffect(() => {
396
+ if (card === null || card.status !== 'loading') return undefined
397
+ const requestId = card.requestId
398
+ const controller = new AbortController()
399
+ const timer = window.setTimeout(() => controller.abort(), 8000)
400
+ let live = true
401
+
402
+ const settle = (patch) => {
403
+ if (!live) return
404
+ setCard((current) => current !== null && current.requestId === requestId ? { ...current, ...patch } : current)
405
+ }
406
+
407
+ fetch(LOOKUP_URL, {
408
+ method: 'POST',
409
+ credentials: 'same-origin',
410
+ headers: { 'content-type': 'application/json' },
411
+ body: JSON.stringify({ word: card.word }),
412
+ signal: controller.signal
413
+ })
414
+ .then((response) => response.json())
415
+ .then((data) => {
416
+ if (data !== null && typeof data === 'object' && data.ok === true) {
417
+ settle({ status: 'ready', result: data })
418
+ } else {
419
+ settle({ status: 'error', code: data !== null && typeof data === 'object' ? data.code ?? 'failed' : 'failed' })
420
+ }
421
+ })
422
+ .catch((error) => {
423
+ settle({ status: 'error', code: error !== null && error !== undefined && error.name === 'AbortError' ? 'timeout' : 'failed' })
424
+ })
425
+ .finally(() => window.clearTimeout(timer))
426
+
427
+ return () => {
428
+ live = false
429
+ window.clearTimeout(timer)
430
+ controller.abort()
431
+ }
432
+ }, [card === null ? null : card.requestId])
433
+
434
+ // Selection trigger.
435
+ useEffect(() => {
436
+ let timer = 0
437
+
438
+ const inspect = () => {
439
+ if (cardRef.current !== null) return
440
+ const selection = readSelection()
441
+ if (selection === null) {
442
+ setPill(null)
443
+ return
444
+ }
445
+ setPill({ ...selection, position: pillPosition(selection.rect) })
446
+ }
447
+
448
+ const onMouseUp = (event) => {
449
+ const target = elementOf(event.target)
450
+ if (target !== null && target.closest('.dlu-root') !== null) return
451
+ window.clearTimeout(timer)
452
+ timer = window.setTimeout(inspect, 0)
453
+ }
454
+
455
+ const onKeyUp = (event) => {
456
+ if (event.key !== 'Shift' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
457
+ window.clearTimeout(timer)
458
+ timer = window.setTimeout(inspect, 0)
459
+ }
460
+
461
+ window.addEventListener('mouseup', onMouseUp)
462
+ window.addEventListener('keyup', onKeyUp)
463
+ return () => {
464
+ window.clearTimeout(timer)
465
+ window.removeEventListener('mouseup', onMouseUp)
466
+ window.removeEventListener('keyup', onKeyUp)
467
+ }
468
+ }, [])
469
+
470
+ // Dismissal: outside click, Escape, page scroll, viewport resize.
471
+ useEffect(() => {
472
+ const onMouseDown = (event) => {
473
+ const target = elementOf(event.target)
474
+ if (target !== null && target.closest('.dlu-root') !== null) return
475
+ if (cardRef.current !== null) setCard(null)
476
+ setPill(null)
477
+ }
478
+
479
+ const onKeyDown = (event) => {
480
+ if (event.key === 'Escape') {
481
+ setPill(null)
482
+ setCard(null)
483
+ }
484
+ }
485
+
486
+ const onScroll = (event) => {
487
+ const target = elementOf(event.target)
488
+ if (target !== null && target.closest('.dlu-card') !== null) return
489
+ setPill(null)
490
+ if (cardRef.current !== null) setCard(null)
491
+ }
492
+
493
+ const onResize = () => {
494
+ setPill(null)
495
+ if (cardRef.current !== null) setCard(null)
496
+ }
497
+
498
+ const onSelectionChange = () => {
499
+ if (cardRef.current !== null) return
500
+ const selection = window.getSelection()
501
+ if (selection === null || selection === undefined || selection.isCollapsed) setPill(null)
502
+ }
503
+
504
+ window.addEventListener('mousedown', onMouseDown)
505
+ window.addEventListener('keydown', onKeyDown)
506
+ window.addEventListener('resize', onResize)
507
+ document.addEventListener('scroll', onScroll, true)
508
+ document.addEventListener('selectionchange', onSelectionChange)
509
+ return () => {
510
+ window.removeEventListener('mousedown', onMouseDown)
511
+ window.removeEventListener('keydown', onKeyDown)
512
+ window.removeEventListener('resize', onResize)
513
+ document.removeEventListener('scroll', onScroll, true)
514
+ document.removeEventListener('selectionchange', onSelectionChange)
515
+ }
516
+ }, [])
517
+
518
+ const copyWord = useCallback((word) => {
519
+ const finish = () => {
520
+ setCopied(true)
521
+ window.setTimeout(() => setCopied(false), 1400)
522
+ }
523
+ if (navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
524
+ navigator.clipboard.writeText(word).then(finish, finish)
525
+ return
526
+ }
527
+ finish()
528
+ }, [])
529
+
530
+ const openInDictionary = useCallback((word) => {
531
+ setOpening(true)
532
+ fetch(OPEN_URL, {
533
+ method: 'POST',
534
+ credentials: 'same-origin',
535
+ headers: { 'content-type': 'application/json' },
536
+ body: JSON.stringify({ word })
537
+ })
538
+ .catch(() => undefined)
539
+ .finally(() => setOpening(false))
540
+ }, [])
541
+
542
+ const result = card !== null && card.status === 'ready' ? card.result : null
543
+ const parsed = result !== null ? parseDefinition(result.definition, result.word ?? card.word) : null
544
+
545
+ const errorText = (() => {
546
+ if (card === null || card.status !== 'error') return ''
547
+ if (card.code === 'unsupported') return t('unsupported')
548
+ if (card.code === 'helper-missing') return t('helperMissing')
549
+ if (card.code === 'timeout') return t('timeout')
550
+ if (card.code === 'no-entry') return t('noEntry', { word: card.word })
551
+ return t('failed')
552
+ })()
553
+
554
+ return h('div', { className: 'dlu-root', style: { pointerEvents: 'none' } },
555
+ h('style', null, CSS),
556
+
557
+ pill !== null
558
+ ? h('button', {
559
+ type: 'button',
560
+ className: 'dlu-pill',
561
+ style: { left: `${pill.position.left}px`, top: `${pill.position.top}px` },
562
+ title: t('lookUpFor', { word: pill.word }),
563
+ 'aria-label': t('lookUpFor', { word: pill.word }),
564
+ onMouseDown: (event) => {
565
+ event.preventDefault()
566
+ event.stopPropagation()
567
+ },
568
+ onClick: (event) => {
569
+ event.preventDefault()
570
+ event.stopPropagation()
571
+ openCard(pill)
572
+ }
573
+ }, ICON_LOOKUP, h('span', null, t('lookUp')))
574
+ : null,
575
+
576
+ card !== null
577
+ ? h('div', {
578
+ className: 'dlu-card',
579
+ role: 'dialog',
580
+ 'aria-label': card.word,
581
+ style: {
582
+ left: `${card.position.left}px`,
583
+ width: `${card.position.width}px`,
584
+ ...(card.position.top !== undefined ? { top: `${card.position.top}px` } : {}),
585
+ ...(card.position.bottom !== undefined ? { bottom: `${card.position.bottom}px` } : {})
586
+ }
587
+ },
588
+ h('div', { className: 'dlu-head' },
589
+ h('div', { className: 'dlu-titles' },
590
+ h('div', { className: 'dlu-word' }, result !== null ? result.word ?? card.word : card.word),
591
+ parsed !== null && parsed.phonetic !== '' ? h('div', { className: 'dlu-phonetic' }, parsed.phonetic) : null
592
+ ),
593
+ h('button', {
594
+ type: 'button',
595
+ className: 'dlu-close',
596
+ 'aria-label': t('close'),
597
+ title: t('close'),
598
+ onMouseDown: (event) => event.stopPropagation(),
599
+ onClick: () => setCard(null)
600
+ }, ICON_CLOSE)
601
+ ),
602
+ h('div', { className: 'dlu-body', style: { maxHeight: `${card.position.maxHeight}px` } },
603
+ card.status === 'loading'
604
+ ? h('div', { className: 'dlu-loading' },
605
+ h('span', null, t('loading')),
606
+ h('span', { className: 'dlu-dots' },
607
+ h('span', { className: 'dlu-dot' }),
608
+ h('span', { className: 'dlu-dot' }),
609
+ h('span', { className: 'dlu-dot' })
610
+ )
611
+ )
612
+ : null,
613
+ card.status === 'error'
614
+ ? h('div', { className: 'dlu-empty is-error' }, errorText)
615
+ : null,
616
+ card.status === 'ready' && parsed !== null
617
+ ? parsed.paragraphs.map((paragraph, index) => h('p', { key: index }, paragraph))
618
+ : null
619
+ ),
620
+ h('div', { className: 'dlu-foot' },
621
+ h('span', { className: 'dlu-source' }, t('source')),
622
+ card.status === 'ready'
623
+ ? h('button', {
624
+ type: 'button',
625
+ className: 'dlu-btn',
626
+ 'data-copied': copied ? 'true' : undefined,
627
+ onMouseDown: (event) => event.stopPropagation(),
628
+ onClick: () => copyWord(result.word ?? card.word)
629
+ }, copied ? t('copied') : t('copy'))
630
+ : null,
631
+ card.status === 'ready' && navigator.platform !== undefined && /Mac/u.test(navigator.platform)
632
+ ? h('button', {
633
+ type: 'button',
634
+ className: 'dlu-btn',
635
+ disabled: opening,
636
+ onMouseDown: (event) => event.stopPropagation(),
637
+ onClick: () => openInDictionary(result.word ?? card.word)
638
+ }, t('openInDictionary'))
639
+ : null
640
+ )
641
+ )
642
+ : null
643
+ )
644
+ }
645
+
646
+ return {
647
+ inject: ['slots', 'locale'],
648
+ apply(ctx) {
649
+ ctx.effect(() => ctx.locale.register(NS, STRINGS), 'look-up: copy')
650
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
651
+ name: 'shell.overlay',
652
+ id: 'look-up',
653
+ order: 55
654
+ }, () => h(LookUpLayer, { locale: ctx.locale })))
655
+ }
656
+ }
657
+ }
658
+ })
@@ -0,0 +1,7 @@
1
+ # dsh-plugin-look-up bundle patch
2
+ #
3
+ # One Loader row. The package is both a Host plugin (the local dictionary route)
4
+ # and a Client bundle (the selection pill and definition card in the Web UI).
5
+ - insert:
6
+ - id: look-up
7
+ name: dsh-plugin-look-up
package/icon.svg ADDED
@@ -0,0 +1,18 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Look Up">
2
+ <defs>
3
+ <linearGradient id="dlu-bg" x1="8" y1="4" x2="58" y2="62" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#4f8df7"/>
5
+ <stop offset="1" stop-color="#1e3a8a"/>
6
+ </linearGradient>
7
+ <linearGradient id="dlu-page" x1="20" y1="14" x2="44" y2="50" gradientUnits="userSpaceOnUse">
8
+ <stop stop-color="#ffffff" stop-opacity=".98"/>
9
+ <stop offset="1" stop-color="#dbeafe" stop-opacity=".9"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect x="2" y="2" width="60" height="60" rx="15" fill="url(#dlu-bg)"/>
13
+ <rect x="2" y="2" width="60" height="60" rx="15" fill="none" stroke="#ffffff" stroke-opacity=".18"/>
14
+ <path d="M20 15h16.5a7 7 0 0 1 7 7v20.5a7 7 0 0 1-7 7H20z" fill="url(#dlu-page)"/>
15
+ <path d="M26 23h11M26 29h11M26 35h7" stroke="#1e3a8a" stroke-opacity=".42" stroke-width="2.6" stroke-linecap="round"/>
16
+ <circle cx="34.5" cy="37.5" r="9.2" fill="#1e3a8a" fill-opacity=".22" stroke="#ffffff" stroke-width="3.6"/>
17
+ <path d="M41.2 44.2 49.6 52.6" stroke="#ffffff" stroke-width="4.4" stroke-linecap="round"/>
18
+ </svg>
package/index.js ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * dsh-plugin-look-up — Host half.
3
+ *
4
+ * The browser half owns the interaction; this half owns the only privileged
5
+ * step: asking macOS for a definition. `bin/dsh-lookup-dict` is a small signed
6
+ * universal helper that calls DictionaryServices' `DCSCopyTextDefinition`, the
7
+ * same local dictionary data macOS Look Up reads. The Host never needs network
8
+ * access, accessibility permission, or an Apple Events grant.
9
+ *
10
+ * Routes (loopback only, same-origin JSON):
11
+ * GET /plugin/look-up/health → mount, platform and helper status
12
+ * POST /plugin/look-up/lookup {word} → { ok, word, definition } | { ok:false, code }
13
+ * POST /plugin/look-up/open {word} → open the word in Dictionary.app
14
+ */
15
+
16
+ import { execFile } from 'node:child_process'
17
+ import { accessSync, constants, readFileSync } from 'node:fs'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { promisify } from 'node:util'
20
+
21
+ /** Cordis plugin name. */
22
+ export const name = 'look-up'
23
+
24
+ /** Required Host service: the route registry. */
25
+ export const inject = ['webServer']
26
+
27
+ /** Package name; also the Client bundle row id. */
28
+ export const PLUGIN_ID = 'dsh-plugin-look-up'
29
+
30
+ /** Route prefix owned by this plugin. */
31
+ export const ROUTE_PREFIX = '/plugin/look-up'
32
+
33
+ const execFileAsync = promisify(execFile)
34
+ const MANIFEST = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
35
+ const HELPER_PATH = fileURLToPath(new URL('./bin/dsh-lookup-dict', import.meta.url))
36
+ const MAX_WORD_LENGTH = 64
37
+ const MAX_BODY_BYTES = 8 * 1024
38
+ const MAX_CACHE_ENTRIES = 256
39
+ const HELPER_TIMEOUT_MS = 5000
40
+
41
+ /** In-memory definition cache; definitions are immutable for a given word. */
42
+ const cache = new Map()
43
+
44
+ /** Whether the packaged helper exists and is executable. */
45
+ function helperStatus() {
46
+ if (process.platform !== 'darwin') return 'unsupported'
47
+ try {
48
+ accessSync(HELPER_PATH, constants.X_OK)
49
+ return 'ok'
50
+ } catch {
51
+ return 'missing'
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Whether the request arrived on a loopback Host.
57
+ * @param req - the incoming request.
58
+ * @returns true for 127.0.0.1, localhost, or [::1].
59
+ */
60
+ export function loopbackHost(req) {
61
+ const host = req.headers.host
62
+ if (typeof host !== 'string' || host === '') return false
63
+ if (host.startsWith('[')) return host.startsWith('[::1]')
64
+ const name = host.split(':')[0]
65
+ return name === '127.0.0.1' || name === 'localhost'
66
+ }
67
+
68
+ /**
69
+ * Normalize one user-supplied word.
70
+ * @param input - untrusted JSON value.
71
+ * @returns the normalized word, or null when it is not usable.
72
+ */
73
+ export function normalizeWord(input) {
74
+ if (typeof input !== 'string') return null
75
+ const word = input.replace(/\s+/gu, ' ').trim()
76
+ if (word.length === 0 || word.length > MAX_WORD_LENGTH) return null
77
+ // Control characters are never part of a dictionary headword and would only
78
+ // make it into process arguments or the Dictionary URL.
79
+ if (/[\u0000-\u001f\u007f]/u.test(word)) return null
80
+ return word
81
+ }
82
+
83
+ /** Write one JSON response. */
84
+ function sendJson(res, status, payload) {
85
+ const body = Buffer.from(`${JSON.stringify(payload)}\n`)
86
+ res.writeHead(status, {
87
+ 'content-type': 'application/json; charset=utf-8',
88
+ 'content-length': String(body.byteLength),
89
+ 'cache-control': 'no-store',
90
+ 'x-content-type-options': 'nosniff'
91
+ })
92
+ res.end(body)
93
+ }
94
+
95
+ /**
96
+ * Read a bounded JSON request body.
97
+ * @param req - the incoming request.
98
+ * @returns the parsed JSON object (an empty object for an empty body).
99
+ */
100
+ async function readJson(req) {
101
+ const chunks = []
102
+ let size = 0
103
+ for await (const chunk of req) {
104
+ size += chunk.length
105
+ if (size > MAX_BODY_BYTES) throw new Error('body-too-large')
106
+ chunks.push(chunk)
107
+ }
108
+ if (size === 0) return {}
109
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
110
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('body-invalid')
111
+ return parsed
112
+ }
113
+
114
+ /** Store one result in the definition cache, evicting the oldest entry. */
115
+ function remember(word, value) {
116
+ cache.set(word, value)
117
+ if (cache.size > MAX_CACHE_ENTRIES) {
118
+ const oldest = cache.keys().next().value
119
+ if (oldest !== undefined) cache.delete(oldest)
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Ask the macOS helper for one definition.
125
+ * @param word - a normalized word.
126
+ * @returns a client-facing result object.
127
+ */
128
+ async function lookupWord(word) {
129
+ const cached = cache.get(word)
130
+ if (cached !== undefined) return cached
131
+
132
+ if (process.platform !== 'darwin') return { ok: false, code: 'unsupported', word }
133
+ if (helperStatus() !== 'ok') return { ok: false, code: 'helper-missing', word }
134
+
135
+ let stdout
136
+ try {
137
+ const result = await execFileAsync(HELPER_PATH, [word], {
138
+ timeout: HELPER_TIMEOUT_MS,
139
+ maxBuffer: 1024 * 1024,
140
+ encoding: 'utf8',
141
+ windowsHide: true
142
+ })
143
+ stdout = result.stdout
144
+ } catch (error) {
145
+ if (error?.code === 'ETIMEDOUT' || error?.killed === true) return { ok: false, code: 'timeout', word }
146
+ // The helper reports "no entry" as JSON with exit code 0, so a thrown
147
+ // error here means the process itself failed. Some spawn errors still
148
+ // carry usable stdout, but we do not trust partial output.
149
+ return { ok: false, code: 'helper-failed', word }
150
+ }
151
+
152
+ let parsed
153
+ try {
154
+ parsed = JSON.parse(stdout.trim())
155
+ } catch {
156
+ return { ok: false, code: 'helper-failed', word }
157
+ }
158
+
159
+ const result = parsed.ok === true
160
+ ? { ok: true, word, definition: String(parsed.definition ?? ''), source: 'DictionaryServices' }
161
+ : { ok: false, code: typeof parsed.error === 'string' ? parsed.error : 'no-entry', word }
162
+ if (result.ok) remember(word, result)
163
+ return result
164
+ }
165
+
166
+ /**
167
+ * Open one word in Dictionary.app through its registered `dict:` URL scheme.
168
+ * @param word - a normalized word.
169
+ * @returns a client-facing result object.
170
+ */
171
+ async function openInDictionary(word) {
172
+ if (process.platform !== 'darwin') return { ok: false, code: 'unsupported' }
173
+ try {
174
+ await execFileAsync('/usr/bin/open', ['-g', `dict://${encodeURIComponent(word)}`], {
175
+ timeout: HELPER_TIMEOUT_MS,
176
+ encoding: 'utf8'
177
+ })
178
+ return { ok: true }
179
+ } catch {
180
+ return { ok: false, code: 'open-failed' }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Dispatch one request under the plugin's route prefix.
186
+ * @param ctx - the Host plugin context.
187
+ * @param req - the incoming request.
188
+ * @param res - the response to write.
189
+ */
190
+ async function serve(ctx, req, res) {
191
+ if (!loopbackHost(req)) {
192
+ sendJson(res, 403, { ok: false, code: 'loopback-only' })
193
+ return
194
+ }
195
+
196
+ const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
197
+
198
+ if (pathname === `${ROUTE_PREFIX}/health`) {
199
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
200
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
201
+ return
202
+ }
203
+ const clientModules = typeof ctx.get === 'function' ? ctx.get('clientModules') : undefined
204
+ const clientBundle = clientModules !== undefined && typeof clientModules.clientPath === 'function'
205
+ ? clientModules.clientPath(PLUGIN_ID)
206
+ : undefined
207
+ sendJson(res, 200, {
208
+ ok: true,
209
+ plugin: PLUGIN_ID,
210
+ version: MANIFEST.version,
211
+ platform: process.platform,
212
+ helper: helperStatus(),
213
+ cacheEntries: cache.size,
214
+ clientRegistered: clientBundle !== undefined,
215
+ clientBundle: clientBundle ?? null
216
+ })
217
+ return
218
+ }
219
+
220
+ if (pathname !== `${ROUTE_PREFIX}/lookup` && pathname !== `${ROUTE_PREFIX}/open`) {
221
+ sendJson(res, 404, { ok: false, code: 'not-found' })
222
+ return
223
+ }
224
+ if (req.method !== 'POST') {
225
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
226
+ return
227
+ }
228
+
229
+ let body
230
+ try {
231
+ body = await readJson(req)
232
+ } catch (error) {
233
+ sendJson(res, error instanceof Error && error.message === 'body-too-large' ? 413 : 400, {
234
+ ok: false,
235
+ code: error instanceof Error ? error.message : 'body-invalid'
236
+ })
237
+ return
238
+ }
239
+
240
+ const word = normalizeWord(body.word)
241
+ if (word === null) {
242
+ sendJson(res, 400, { ok: false, code: 'invalid-word' })
243
+ return
244
+ }
245
+
246
+ const result = pathname === `${ROUTE_PREFIX}/lookup`
247
+ ? await lookupWord(word)
248
+ : await openInDictionary(word)
249
+ sendJson(res, 200, result)
250
+ }
251
+
252
+ /**
253
+ * Register the plugin's routes for the lifetime of its Loader row.
254
+ * @param ctx - the Host plugin context carrying `webServer`.
255
+ */
256
+ export function apply(ctx) {
257
+ ctx.effect(
258
+ () => ctx.webServer.register({
259
+ kind: 'prefix',
260
+ path: ROUTE_PREFIX,
261
+ handler: (req, res) => {
262
+ void serve(ctx, req, res)
263
+ }
264
+ }),
265
+ 'look-up: routes'
266
+ )
267
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Look Up",
4
+ "description": "Select a word in chat and read macOS system-dictionary definitions in place — offline, no network."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "查词",
4
+ "description": "在对话里选中单词,就地查看 macOS 系统词典释义——离线、不联网。"
5
+ }
6
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * dsh-lookup-dict — tiny macOS system-dictionary bridge for dsh-plugin-look-up.
3
+ *
4
+ * Reads the definition of one word from the dictionaries the user configured in
5
+ * Dictionary.app by calling DictionaryServices' public `DCSCopyTextDefinition`.
6
+ * That is the same local, offline data source macOS Look Up uses; no network,
7
+ * no accessibility permission, no Apple Events.
8
+ *
9
+ * Usage:
10
+ * dsh-lookup-dict <word> -> one JSON object on stdout
11
+ * dsh-lookup-dict --version-> "1"
12
+ *
13
+ * Exit codes: 0 when a JSON object was written (including `ok:false`),
14
+ * 2 on usage errors. Output is always valid JSON otherwise.
15
+ */
16
+
17
+ #import <Foundation/Foundation.h>
18
+ #import <CoreServices/CoreServices.h>
19
+
20
+ static void write_json(NSDictionary *payload) {
21
+ NSError *error = nil;
22
+ NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
23
+ if (data == nil) {
24
+ fputs("{\"ok\":false,\"error\":\"encode\"}\n", stdout);
25
+ return;
26
+ }
27
+ fwrite(data.bytes, 1, data.length, stdout);
28
+ fputc('\n', stdout);
29
+ }
30
+
31
+ int main(int argc, const char *argv[]) {
32
+ @autoreleasepool {
33
+ if (argc > 1 && strcmp(argv[1], "--version") == 0) {
34
+ fputs("1\n", stdout);
35
+ return 0;
36
+ }
37
+ if (argc < 2) {
38
+ write_json(@{@"ok": @NO, @"error": @"usage"});
39
+ return 2;
40
+ }
41
+
42
+ NSString *raw = [[NSString alloc] initWithUTF8String:argv[1]];
43
+ if (raw == nil) {
44
+ write_json(@{@"ok": @NO, @"error": @"encoding"});
45
+ return 0;
46
+ }
47
+ NSString *word = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
48
+ if (word.length == 0) {
49
+ write_json(@{@"ok": @NO, @"error": @"empty"});
50
+ return 0;
51
+ }
52
+ if (word.length > 128) {
53
+ write_json(@{@"ok": @NO, @"error": @"too-long"});
54
+ return 0;
55
+ }
56
+
57
+ CFRange range = CFRangeMake(0, (CFIndex)word.length);
58
+ CFStringRef copied = DCSCopyTextDefinition(NULL, (__bridge CFStringRef)word, range);
59
+ if (copied == NULL) {
60
+ write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
61
+ return 0;
62
+ }
63
+
64
+ NSString *definition = (__bridge_transfer NSString *)copied;
65
+ NSString *trimmed = [definition stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
66
+ if (trimmed.length == 0) {
67
+ write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
68
+ return 0;
69
+ }
70
+
71
+ write_json(@{
72
+ @"ok": @YES,
73
+ @"word": word,
74
+ @"definition": trimmed,
75
+ @"source": @"DictionaryServices"
76
+ });
77
+ }
78
+ return 0;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "dsh-plugin-look-up",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "macOS Look Up inside DSH chat: select a word and read system-dictionary definitions in place, offline.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "huanglianqi",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/huanglianqi/dsh-plugin-look-up.git"
11
+ },
12
+ "homepage": "https://github.com/huanglianqi/dsh-plugin-look-up#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/huanglianqi/dsh-plugin-look-up/issues"
15
+ },
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-plugin",
19
+ "deepseek-harness",
20
+ "deepseek-harness-plugin",
21
+ "dictionary",
22
+ "look-up",
23
+ "macos",
24
+ "define"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "exports": {
30
+ ".": "./index.js",
31
+ "./client": "./client.js",
32
+ "./locale/*.json": "./locale/*.json",
33
+ "./icon": "./icon.svg"
34
+ },
35
+ "files": [
36
+ "index.js",
37
+ "client.js",
38
+ "cordis.patch.yml",
39
+ "locale/*.json",
40
+ "icon.svg",
41
+ "bin/dsh-lookup-dict",
42
+ "native/dsh-lookup-dict.m",
43
+ "scripts/build-helper.sh",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "scripts": {
48
+ "build:helper": "bash scripts/build-helper.sh",
49
+ "check": "node --check index.js && node --check client.js",
50
+ "test": "node --test test/*.test.mjs"
51
+ },
52
+ "dsh": {
53
+ "bundle": {
54
+ "patch": "./cordis.patch.yml"
55
+ },
56
+ "client": {
57
+ "platform": "web",
58
+ "immediately": true,
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-ui-slots",
61
+ "@deepseek-ai/dsh-client-locale"
62
+ ]
63
+ }
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Rebuild bin/dsh-lookup-dict as a signed universal (arm64 + x86_64) helper.
3
+ set -euo pipefail
4
+ cd "$(dirname "$0")/.."
5
+
6
+ if [[ "$(uname -s)" != "Darwin" ]]; then
7
+ echo "build-helper.sh: macOS only" >&2
8
+ exit 1
9
+ fi
10
+
11
+ mkdir -p bin
12
+ xcrun clang \
13
+ -O2 \
14
+ -fobjc-arc \
15
+ -arch arm64 \
16
+ -arch x86_64 \
17
+ -mmacosx-version-min=11.0 \
18
+ -Wno-deprecated-declarations \
19
+ -framework Foundation \
20
+ -framework CoreServices \
21
+ -o bin/dsh-lookup-dict \
22
+ native/dsh-lookup-dict.m
23
+
24
+ # ad-hoc signature keeps the arm64 slice runnable after lipo merges it.
25
+ codesign --force --sign - bin/dsh-lookup-dict
26
+
27
+ echo "built bin/dsh-lookup-dict ($(lipo -archs bin/dsh-lookup-dict))"
28
+ ./bin/dsh-lookup-dict --version