@talex-touch/utils 1.0.39 → 1.0.42

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.
@@ -0,0 +1,213 @@
1
+ import type { IManifest } from '..'
2
+ import type {
3
+ PluginInstallRequest,
4
+ PluginInstallResult,
5
+ PluginProvider,
6
+ PluginProviderContext,
7
+ } from './types'
8
+ import { PluginProviderType } from './types'
9
+
10
+ const NPM_REGISTRY = 'https://registry.npmjs.org'
11
+ const TUFF_PLUGIN_PREFIX = 'tuff-plugin-'
12
+ const TUFF_PLUGIN_SCOPE = '@tuff/'
13
+
14
+ export interface NpmPackageInfo {
15
+ name: string
16
+ version: string
17
+ description?: string
18
+ author?: string | { name: string, email?: string }
19
+ main?: string
20
+ keywords?: string[]
21
+ dist: {
22
+ tarball: string
23
+ shasum: string
24
+ integrity?: string
25
+ }
26
+ tuff?: {
27
+ icon?: string
28
+ activationKeywords?: string[]
29
+ }
30
+ }
31
+
32
+ export interface NpmPackageVersions {
33
+ name: string
34
+ 'dist-tags': {
35
+ latest: string
36
+ [tag: string]: string
37
+ }
38
+ versions: Record<string, NpmPackageInfo>
39
+ }
40
+
41
+ /**
42
+ * Parse NPM source string to extract package name and optional version
43
+ * Formats:
44
+ * - "npm:package-name"
45
+ * - "npm:package-name@version"
46
+ * - "npm:@scope/package-name"
47
+ * - "npm:@scope/package-name@version"
48
+ * - "tuff-plugin-xxx" (when hintType is NPM)
49
+ * - "@tuff/xxx" (when hintType is NPM)
50
+ */
51
+ function parseNpmSource(source: string): { packageName: string, version?: string } | null {
52
+ const npmMatch = source.match(/^npm:(@?[a-z0-9][\w\-.]*(?:\/[a-z0-9][\w\-.]*)?)(?:@(.+))?$/i)
53
+ if (npmMatch) {
54
+ return { packageName: npmMatch[1], version: npmMatch[2] }
55
+ }
56
+
57
+ const scopedMatch = source.match(/^(@tuff\/[a-z0-9][\w\-.]*)(?:@(.+))?$/i)
58
+ if (scopedMatch) {
59
+ return { packageName: scopedMatch[1], version: scopedMatch[2] }
60
+ }
61
+
62
+ const prefixMatch = source.match(/^(tuff-plugin-[a-z0-9][\w\-.]*)(?:@(.+))?$/i)
63
+ if (prefixMatch) {
64
+ return { packageName: prefixMatch[1], version: prefixMatch[2] }
65
+ }
66
+
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Check if a package name looks like a Tuff plugin
72
+ */
73
+ function isTuffPluginPackage(name: string): boolean {
74
+ return name.startsWith(TUFF_PLUGIN_PREFIX) || name.startsWith(TUFF_PLUGIN_SCOPE)
75
+ }
76
+
77
+ export class NpmProvider implements PluginProvider {
78
+ readonly type = PluginProviderType.NPM
79
+ private registry: string
80
+
81
+ constructor(registry: string = NPM_REGISTRY) {
82
+ this.registry = registry.replace(/\/$/, '')
83
+ }
84
+
85
+ canHandle(request: PluginInstallRequest): boolean {
86
+ if (request.hintType === PluginProviderType.NPM) {
87
+ return parseNpmSource(request.source) !== null
88
+ }
89
+
90
+ if (request.source.startsWith('npm:')) {
91
+ return true
92
+ }
93
+
94
+ const parsed = parseNpmSource(request.source)
95
+ return parsed !== null && isTuffPluginPackage(parsed.packageName)
96
+ }
97
+
98
+ async install(
99
+ request: PluginInstallRequest,
100
+ context?: PluginProviderContext,
101
+ ): Promise<PluginInstallResult> {
102
+ const parsed = parseNpmSource(request.source)
103
+ if (!parsed) {
104
+ throw new Error(`Invalid NPM source format: ${request.source}`)
105
+ }
106
+
107
+ const { packageName, version } = parsed
108
+ const packageInfo = await this.getPackageInfo(packageName, version)
109
+
110
+ if (!packageInfo) {
111
+ throw new Error(`Package not found: ${packageName}`)
112
+ }
113
+
114
+ const tarballUrl = packageInfo.dist.tarball
115
+ const downloadRes = await fetch(tarballUrl)
116
+ if (!downloadRes.ok) {
117
+ throw new Error(`Failed to download package: ${downloadRes.statusText}`)
118
+ }
119
+
120
+ const arrayBuffer = await downloadRes.arrayBuffer()
121
+ const tempDir = context?.tempDir ?? '/tmp'
122
+ const safePackageName = packageName.replace(/[@/]/g, '-')
123
+ const fileName = `${safePackageName}-${packageInfo.version}.tgz`
124
+ const filePath = `${tempDir}/${fileName}`
125
+
126
+ if (typeof globalThis.process !== 'undefined') {
127
+ const fs = await import('node:fs/promises')
128
+ await fs.writeFile(filePath, Buffer.from(arrayBuffer))
129
+ }
130
+
131
+ const authorName = typeof packageInfo.author === 'string'
132
+ ? packageInfo.author
133
+ : packageInfo.author?.name ?? 'Unknown'
134
+
135
+ const manifest: IManifest = {
136
+ id: packageInfo.name,
137
+ name: packageInfo.name.replace(TUFF_PLUGIN_PREFIX, '').replace(TUFF_PLUGIN_SCOPE, ''),
138
+ version: packageInfo.version,
139
+ description: packageInfo.description ?? '',
140
+ author: authorName,
141
+ main: packageInfo.main ?? 'index.js',
142
+ icon: packageInfo.tuff?.icon,
143
+ activationKeywords: packageInfo.tuff?.activationKeywords ?? packageInfo.keywords,
144
+ }
145
+
146
+ return {
147
+ provider: PluginProviderType.NPM,
148
+ filePath,
149
+ official: packageName.startsWith(TUFF_PLUGIN_SCOPE),
150
+ manifest,
151
+ metadata: {
152
+ packageName: packageInfo.name,
153
+ version: packageInfo.version,
154
+ tarball: tarballUrl,
155
+ shasum: packageInfo.dist.shasum,
156
+ integrity: packageInfo.dist.integrity,
157
+ },
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Get package info from npm registry
163
+ */
164
+ async getPackageInfo(packageName: string, version?: string): Promise<NpmPackageInfo | null> {
165
+ const encodedName = encodeURIComponent(packageName).replace('%40', '@')
166
+ const res = await fetch(`${this.registry}/${encodedName}`)
167
+
168
+ if (!res.ok) {
169
+ if (res.status === 404) return null
170
+ throw new Error(`Failed to fetch package info: ${res.statusText}`)
171
+ }
172
+
173
+ const data: NpmPackageVersions = await res.json()
174
+ const targetVersion = version ?? data['dist-tags'].latest
175
+
176
+ return data.versions[targetVersion] ?? null
177
+ }
178
+
179
+ /**
180
+ * Search for Tuff plugins on npm
181
+ */
182
+ async searchPlugins(keyword?: string): Promise<NpmPackageInfo[]> {
183
+ const searchTerms = [
184
+ `keywords:tuff-plugin`,
185
+ keyword ? `${keyword}` : '',
186
+ ].filter(Boolean).join('+')
187
+
188
+ const res = await fetch(
189
+ `${this.registry}/-/v1/search?text=${encodeURIComponent(searchTerms)}&size=100`,
190
+ )
191
+
192
+ if (!res.ok) {
193
+ throw new Error(`Failed to search packages: ${res.statusText}`)
194
+ }
195
+
196
+ const data = await res.json() as {
197
+ objects: Array<{ package: NpmPackageInfo }>
198
+ }
199
+
200
+ return data.objects
201
+ .map(obj => obj.package)
202
+ .filter(pkg => isTuffPluginPackage(pkg.name))
203
+ }
204
+
205
+ /**
206
+ * List all available Tuff plugins from npm
207
+ */
208
+ async listPlugins(): Promise<NpmPackageInfo[]> {
209
+ return this.searchPlugins()
210
+ }
211
+ }
212
+
213
+ export const npmProvider = new NpmProvider()
@@ -0,0 +1,283 @@
1
+ import type { IManifest } from '..'
2
+ import type {
3
+ PluginInstallRequest,
4
+ PluginInstallResult,
5
+ PluginProvider,
6
+ PluginProviderContext,
7
+ } from './types'
8
+ import { PluginProviderType } from './types'
9
+
10
+ const DEFAULT_TPEX_API = 'https://tuff.tagzxia.com'
11
+
12
+ /**
13
+ * Check if source is a .tpex file path or URL
14
+ */
15
+ function isTpexFile(source: string): boolean {
16
+ return source.trim().toLowerCase().endsWith('.tpex')
17
+ }
18
+
19
+ /**
20
+ * Check if source is a remote URL
21
+ */
22
+ function isRemoteUrl(source: string): boolean {
23
+ return /^https?:\/\//i.test(source)
24
+ }
25
+
26
+ export interface TpexPluginInfo {
27
+ id: string
28
+ slug: string
29
+ name: string
30
+ summary: string
31
+ category: string
32
+ installs: number
33
+ homepage?: string | null
34
+ isOfficial: boolean
35
+ badges: string[]
36
+ author?: { name: string, avatarColor?: string } | null
37
+ iconUrl?: string | null
38
+ latestVersion?: {
39
+ id: string
40
+ version: string
41
+ channel: string
42
+ packageUrl: string
43
+ packageSize: number
44
+ manifest?: Record<string, unknown> | null
45
+ changelog?: string | null
46
+ }
47
+ }
48
+
49
+ export interface TpexListResponse {
50
+ plugins: TpexPluginInfo[]
51
+ total: number
52
+ }
53
+
54
+ export interface TpexDetailResponse {
55
+ plugin: TpexPluginInfo & {
56
+ versions?: Array<{
57
+ id: string
58
+ version: string
59
+ channel: string
60
+ packageUrl: string
61
+ packageSize: number
62
+ manifest?: Record<string, unknown> | null
63
+ changelog?: string | null
64
+ }>
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Parse TPEX source string to extract slug and optional version
70
+ * Formats: "tpex:slug", "tpex:slug@version", "slug" (when hintType is TPEX)
71
+ */
72
+ function parseTpexSource(source: string): { slug: string, version?: string } | null {
73
+ const tpexMatch = source.match(/^tpex:([a-z0-9][a-z0-9\-_.]{1,62}[a-z0-9])(?:@(.+))?$/i)
74
+ if (tpexMatch) {
75
+ return { slug: tpexMatch[1], version: tpexMatch[2] }
76
+ }
77
+
78
+ const slugMatch = source.match(/^([a-z0-9][a-z0-9\-_.]{1,62}[a-z0-9])(?:@(.+))?$/i)
79
+ if (slugMatch) {
80
+ return { slug: slugMatch[1], version: slugMatch[2] }
81
+ }
82
+
83
+ return null
84
+ }
85
+
86
+ export class TpexProvider implements PluginProvider {
87
+ readonly type = PluginProviderType.TPEX
88
+ private apiBase: string
89
+
90
+ constructor(apiBase: string = DEFAULT_TPEX_API) {
91
+ this.apiBase = apiBase.replace(/\/$/, '')
92
+ }
93
+
94
+ canHandle(request: PluginInstallRequest): boolean {
95
+ // Handle .tpex file paths (local or remote URL)
96
+ if (isTpexFile(request.source)) {
97
+ return true
98
+ }
99
+
100
+ if (request.hintType === PluginProviderType.TPEX) {
101
+ return parseTpexSource(request.source) !== null
102
+ }
103
+ return request.source.startsWith('tpex:')
104
+ }
105
+
106
+ async install(
107
+ request: PluginInstallRequest,
108
+ context?: PluginProviderContext,
109
+ ): Promise<PluginInstallResult> {
110
+ // Handle .tpex file directly (local path or remote URL)
111
+ if (isTpexFile(request.source)) {
112
+ return this.installFromFile(request, context)
113
+ }
114
+
115
+ // Handle tpex:slug format - fetch from API
116
+ return this.installFromRegistry(request, context)
117
+ }
118
+
119
+ /**
120
+ * Install from a .tpex file (local path or remote URL)
121
+ */
122
+ private async installFromFile(
123
+ request: PluginInstallRequest,
124
+ context?: PluginProviderContext,
125
+ ): Promise<PluginInstallResult> {
126
+ let filePath = request.source
127
+ let arrayBuffer: ArrayBuffer | undefined
128
+
129
+ if (isRemoteUrl(request.source)) {
130
+ // Download remote .tpex file
131
+ const downloadRes = await fetch(request.source)
132
+ if (!downloadRes.ok) {
133
+ throw new Error(`Failed to download TPEX file: ${downloadRes.statusText}`)
134
+ }
135
+
136
+ arrayBuffer = await downloadRes.arrayBuffer()
137
+ const tempDir = context?.tempDir ?? '/tmp'
138
+ const fileName = `tpex-${Date.now()}.tpex`
139
+ filePath = `${tempDir}/${fileName}`
140
+
141
+ if (typeof globalThis.process !== 'undefined') {
142
+ const fs = await import('node:fs/promises')
143
+ await fs.writeFile(filePath, Buffer.from(arrayBuffer))
144
+ }
145
+ }
146
+
147
+ // For local files, just return the path - manifest extraction happens in core-app
148
+ return {
149
+ provider: PluginProviderType.TPEX,
150
+ filePath,
151
+ official: false,
152
+ metadata: {
153
+ sourceType: 'file',
154
+ originalSource: request.source,
155
+ },
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Install from TPEX registry (tpex:slug format)
161
+ */
162
+ private async installFromRegistry(
163
+ request: PluginInstallRequest,
164
+ context?: PluginProviderContext,
165
+ ): Promise<PluginInstallResult> {
166
+ const parsed = parseTpexSource(request.source)
167
+ if (!parsed) {
168
+ throw new Error(`Invalid TPEX source format: ${request.source}`)
169
+ }
170
+
171
+ const { slug, version } = parsed
172
+
173
+ const detailRes = await fetch(`${this.apiBase}/api/market/plugins/${slug}`)
174
+ if (!detailRes.ok) {
175
+ if (detailRes.status === 404) {
176
+ throw new Error(`Plugin not found: ${slug}`)
177
+ }
178
+ throw new Error(`Failed to fetch plugin details: ${detailRes.statusText}`)
179
+ }
180
+
181
+ const detail: TpexDetailResponse = await detailRes.json()
182
+ const plugin = detail.plugin
183
+
184
+ let targetVersion = plugin.latestVersion
185
+ if (version && plugin.versions) {
186
+ targetVersion = plugin.versions.find(v => v.version === version) ?? targetVersion
187
+ }
188
+
189
+ if (!targetVersion?.packageUrl) {
190
+ throw new Error(`No downloadable version found for plugin: ${slug}`)
191
+ }
192
+
193
+ const downloadUrl = targetVersion.packageUrl.startsWith('http')
194
+ ? targetVersion.packageUrl
195
+ : `${this.apiBase}${targetVersion.packageUrl}`
196
+
197
+ const downloadRes = await fetch(downloadUrl)
198
+ if (!downloadRes.ok) {
199
+ throw new Error(`Failed to download plugin package: ${downloadRes.statusText}`)
200
+ }
201
+
202
+ const arrayBuffer = await downloadRes.arrayBuffer()
203
+ const tempDir = context?.tempDir ?? '/tmp'
204
+ const fileName = `${slug}-${targetVersion.version}.tpex`
205
+ const filePath = `${tempDir}/${fileName}`
206
+
207
+ if (typeof globalThis.process !== 'undefined') {
208
+ const fs = await import('node:fs/promises')
209
+ await fs.writeFile(filePath, Buffer.from(arrayBuffer))
210
+ }
211
+
212
+ const manifest: IManifest | undefined = targetVersion.manifest
213
+ ? {
214
+ id: plugin.slug,
215
+ name: plugin.name,
216
+ version: targetVersion.version,
217
+ description: plugin.summary,
218
+ author: plugin.author?.name ?? 'Unknown',
219
+ main: (targetVersion.manifest as Record<string, unknown>).main as string ?? 'index.js',
220
+ icon: plugin.iconUrl ?? undefined,
221
+ ...targetVersion.manifest,
222
+ }
223
+ : undefined
224
+
225
+ return {
226
+ provider: PluginProviderType.TPEX,
227
+ filePath,
228
+ official: plugin.isOfficial,
229
+ manifest,
230
+ metadata: {
231
+ sourceType: 'registry',
232
+ slug: plugin.slug,
233
+ version: targetVersion.version,
234
+ channel: targetVersion.channel,
235
+ packageSize: targetVersion.packageSize,
236
+ installs: plugin.installs,
237
+ },
238
+ }
239
+ }
240
+
241
+ /**
242
+ * List all available plugins from TPEX registry
243
+ */
244
+ async listPlugins(): Promise<TpexPluginInfo[]> {
245
+ const res = await fetch(`${this.apiBase}/api/market/plugins`)
246
+ if (!res.ok) {
247
+ throw new Error(`Failed to fetch plugin list: ${res.statusText}`)
248
+ }
249
+
250
+ const data: TpexListResponse = await res.json()
251
+ return data.plugins
252
+ }
253
+
254
+ /**
255
+ * Get plugin details by slug
256
+ */
257
+ async getPlugin(slug: string): Promise<TpexDetailResponse['plugin'] | null> {
258
+ const res = await fetch(`${this.apiBase}/api/market/plugins/${slug}`)
259
+ if (!res.ok) {
260
+ if (res.status === 404) return null
261
+ throw new Error(`Failed to fetch plugin: ${res.statusText}`)
262
+ }
263
+
264
+ const data: TpexDetailResponse = await res.json()
265
+ return data.plugin
266
+ }
267
+
268
+ /**
269
+ * Search plugins by keyword
270
+ */
271
+ async searchPlugins(keyword: string): Promise<TpexPluginInfo[]> {
272
+ const plugins = await this.listPlugins()
273
+ const lowerKeyword = keyword.toLowerCase()
274
+
275
+ return plugins.filter(plugin =>
276
+ plugin.name.toLowerCase().includes(lowerKeyword)
277
+ || plugin.slug.toLowerCase().includes(lowerKeyword)
278
+ || plugin.summary.toLowerCase().includes(lowerKeyword),
279
+ )
280
+ }
281
+ }
282
+
283
+ export const tpexProvider = new TpexProvider()
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Metadata extracted from a .tpex package file
3
+ */
4
+ export interface TpexMetadata {
5
+ readmeMarkdown?: string | null
6
+ manifest?: Record<string, unknown> | null
7
+ }
8
+
9
+ /**
10
+ * Result of package preview operation
11
+ */
12
+ export interface TpexPackagePreviewResult {
13
+ manifest: Record<string, unknown> | null
14
+ readmeMarkdown: string | null
15
+ }
16
+
17
+ /**
18
+ * Extracted manifest fields from tpex package
19
+ */
20
+ export interface TpexExtractedManifest {
21
+ id?: string
22
+ name?: string
23
+ description?: string
24
+ version?: string
25
+ homepage?: string
26
+ changelog?: string
27
+ channel?: string
28
+ category?: string
29
+ icon?: {
30
+ type?: string
31
+ value?: string
32
+ }
33
+ [key: string]: unknown
34
+ }
@@ -66,6 +66,53 @@ const unsubscribe = plugin.feature.onInputChange((input) => {
66
66
 
67
67
  // 取消监听
68
68
  unsubscribe()
69
+
70
+ // 监听键盘事件(UI View 模式下)
71
+ const unsubscribeKey = plugin.feature.onKeyEvent((event) => {
72
+ if (event.key === 'Enter') {
73
+ // 处理回车键
74
+ submitSelection()
75
+ } else if (event.key === 'ArrowDown') {
76
+ // 向下导航
77
+ selectNext()
78
+ } else if (event.key === 'ArrowUp') {
79
+ // 向上导航
80
+ selectPrev()
81
+ } else if (event.metaKey && event.key === 'k') {
82
+ // 处理 Cmd+K
83
+ openSearch()
84
+ }
85
+ })
86
+ ```
87
+
88
+ ### 3. 键盘事件自动处理
89
+
90
+ 当插件的 UI View 附加到 CoreBox 时,系统会自动处理以下行为:
91
+
92
+ #### ESC 键自动退出
93
+ - 在 UI View 中按下 ESC 键会**自动退出 UI 模式**(deactivate providers)
94
+ - 插件无需手动处理 ESC 键的退出逻辑
95
+ - 这与 CoreBox 主界面的 ESC 行为保持一致
96
+
97
+ #### 键盘事件转发
98
+ 以下按键会从 CoreBox 主输入框转发到插件 UI View:
99
+ - **Enter** - 确认/提交
100
+ - **ArrowUp / ArrowDown** - 上下导航
101
+ - **Meta/Ctrl + 任意键** - 快捷键组合(Cmd+V 除外,用于粘贴)
102
+
103
+ > **注意**:`ArrowLeft` 和 `ArrowRight` 不会被转发,因为它们用于输入框中的文本编辑。如果需要左右导航,请使用 `Meta/Ctrl + ArrowLeft/ArrowRight`。
104
+
105
+ ```typescript
106
+ // 键盘事件数据结构
107
+ interface ForwardedKeyEvent {
108
+ key: string // 按键名称,如 'Enter', 'ArrowDown'
109
+ code: string // 按键代码,如 'Enter', 'ArrowDown'
110
+ metaKey: boolean // Cmd/Win 键是否按下
111
+ ctrlKey: boolean // Ctrl 键是否按下
112
+ altKey: boolean // Alt 键是否按下
113
+ shiftKey: boolean // Shift 键是否按下
114
+ repeat: boolean // 是否为重复按键
115
+ }
69
116
  ```
70
117
 
71
118
  ## 废弃的 API
@@ -151,7 +198,7 @@ onInit(context) {
151
198
  export default {
152
199
  onInit(context) {
153
200
  const { feature, box } = context.utils
154
-
201
+
155
202
  // 监听输入变化
156
203
  feature.onInputChange((input) => {
157
204
  if (input.length > 2) {
@@ -163,10 +210,10 @@ export default {
163
210
  }
164
211
  })
165
212
  },
166
-
213
+
167
214
  onFeatureTriggered(featureId, query, feature) {
168
215
  const { feature: featureSDK, box } = this.context.utils
169
-
216
+
170
217
  // 推送结果
171
218
  featureSDK.pushItems([
172
219
  {
@@ -176,10 +223,10 @@ export default {
176
223
  source: { id: this.pluginName, name: this.pluginName }
177
224
  }
178
225
  ])
179
-
226
+
180
227
  // 扩展窗口显示结果
181
228
  box.expand({ length: 5 })
182
-
229
+
183
230
  // 3秒后隐藏
184
231
  setTimeout(() => {
185
232
  box.hide()
@@ -207,7 +254,8 @@ export default {
207
254
  - `core-box:get-input` - 获取当前输入值
208
255
  - `core-box:set-input` - 设置输入框内容
209
256
  - `core-box:clear-input` - 清空输入框
210
- - `core-box:input-changed` - 输入变化广播(主进程 → 插件)
257
+ - `core-box:input-change` - 输入变化广播(主进程 → 插件)
258
+ - `core-box:key-event` - 键盘事件转发(主进程 → 插件 UI View)
211
259
  - `core-box:set-input-visibility` - 设置输入框可见性(主进程 → 渲染进程)
212
260
  - `core-box:request-input-value` - 请求输入值(主进程 → 渲染进程)
213
261