@talex-touch/utils 1.0.40 → 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,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
+ }
@@ -3,11 +3,23 @@ import { ensureRendererChannel } from '../channel'
3
3
 
4
4
  export type BridgeEvent = BridgeEventForCoreBox
5
5
 
6
- /**
7
- * Defines the shape of a bridge hook function.
8
- * @template T The type of data the hook will receive.
9
- */
10
- export type BridgeHook<T = any> = (data: T) => void
6
+ export interface BridgeEventMeta {
7
+ timestamp: number
8
+ fromCache: boolean
9
+ }
10
+
11
+ export interface BridgeEventPayload<T = any> {
12
+ data: T
13
+ meta: BridgeEventMeta
14
+ }
15
+
16
+ /** @template T The type of data the hook will receive. */
17
+ export type BridgeHook<T = any> = (payload: BridgeEventPayload<T>) => void
18
+
19
+ interface CachedEvent<T = any> {
20
+ data: T
21
+ timestamp: number
22
+ }
11
23
 
12
24
  const __hooks: Record<BridgeEvent, Array<BridgeHook>> = {
13
25
  [BridgeEventForCoreBox.CORE_BOX_INPUT_CHANGE]: [],
@@ -15,42 +27,85 @@ const __hooks: Record<BridgeEvent, Array<BridgeHook>> = {
15
27
  [BridgeEventForCoreBox.CORE_BOX_KEY_EVENT]: [],
16
28
  }
17
29
 
18
- /**
19
- * Injects a hook for a given bridge event.
20
- * @param type The bridge event type.
21
- * @param hook The hook function to inject.
22
- * @returns The wrapped hook function.
23
- * @internal
24
- * @template T The type of data the hook will receive.
25
- */
26
- export function injectBridgeEvent<T>(type: BridgeEvent, hook: BridgeHook<T>) {
27
- const hooks: Array<BridgeHook<T>> = __hooks[type] || (__hooks[type] = [])
30
+ const __eventCache: Map<BridgeEvent, CachedEvent[]> = new Map()
31
+ const __channelRegistered = new Set<BridgeEvent>()
28
32
 
29
- // Only register the channel listener once per event type
30
- if (hooks.length === 0) {
31
- const channel = ensureRendererChannel('[TouchSDK] Bridge channel not available. Make sure hooks run in plugin renderer context.')
33
+ const CACHE_MAX_SIZE: Record<BridgeEvent, number> = {
34
+ [BridgeEventForCoreBox.CORE_BOX_INPUT_CHANGE]: 1,
35
+ [BridgeEventForCoreBox.CORE_BOX_CLIPBOARD_CHANGE]: 1,
36
+ [BridgeEventForCoreBox.CORE_BOX_KEY_EVENT]: 10,
37
+ }
38
+
39
+ function invokeHook<T>(hook: BridgeHook<T>, data: T, fromCache: boolean, timestamp: number): void {
40
+ try {
41
+ hook({ data, meta: { timestamp, fromCache } })
42
+ }
43
+ catch (e) {
44
+ console.error('[TouchSDK] Bridge hook error:', e)
45
+ }
46
+ }
47
+
48
+ function registerEarlyListener(type: BridgeEvent): void {
49
+ if (__channelRegistered.has(type)) return
50
+
51
+ try {
52
+ const channel = ensureRendererChannel()
32
53
  channel.regChannel(type, ({ data }) => {
33
- console.debug(`[TouchSDK] ${type} event received: `, data)
34
- // When the event is received, call all registered hooks for this type
35
- const registeredHooks = __hooks[type]
36
- if (registeredHooks) {
37
- registeredHooks.forEach(h => h(data))
54
+ const timestamp = Date.now()
55
+ const hooks = __hooks[type]
56
+
57
+ if (hooks && hooks.length > 0) {
58
+ hooks.forEach(h => invokeHook(h, data, false, timestamp))
59
+ }
60
+ else {
61
+ if (!__eventCache.has(type)) __eventCache.set(type, [])
62
+ const cache = __eventCache.get(type)!
63
+ const maxSize = CACHE_MAX_SIZE[type] ?? 1
64
+ cache.push({ data, timestamp })
65
+ while (cache.length > maxSize) cache.shift()
66
+ console.debug(`[TouchSDK] ${type} cached, size: ${cache.length}`)
38
67
  }
39
68
  })
69
+ __channelRegistered.add(type)
70
+ }
71
+ catch {
72
+ // Channel not ready yet
40
73
  }
74
+ }
41
75
 
42
- const wrappedHook = (data: T) => {
43
- try {
44
- hook(data)
45
- }
46
- catch (e) {
47
- console.error(`[TouchSDK] ${type} hook error: `, e)
48
- }
76
+ /** Clears the event cache for a specific event type or all types. */
77
+ export function clearBridgeEventCache(type?: BridgeEvent): void {
78
+ if (type) {
79
+ __eventCache.delete(type)
49
80
  }
81
+ else {
82
+ __eventCache.clear()
83
+ }
84
+ }
85
+
86
+ // Auto-init on module load
87
+ ;(function initBridgeEventCache() {
88
+ setTimeout(() => {
89
+ Object.values(BridgeEventForCoreBox).forEach(e => registerEarlyListener(e as BridgeEvent))
90
+ }, 0)
91
+ })()
50
92
 
51
- hooks.push(wrappedHook)
93
+ /** @internal Injects a hook for a given bridge event with cache replay. */
94
+ export function injectBridgeEvent<T>(type: BridgeEvent, hook: BridgeHook<T>) {
95
+ const hooks: Array<BridgeHook<T>> = __hooks[type] || (__hooks[type] = [])
96
+
97
+ // Ensure channel listener is registered
98
+ registerEarlyListener(type)
52
99
 
53
- return wrappedHook
100
+ // Replay cached events to this new hook
101
+ const cached = __eventCache.get(type)
102
+ if (cached && cached.length > 0) {
103
+ cached.forEach(({ data, timestamp }) => invokeHook(hook, data as T, true, timestamp))
104
+ __eventCache.delete(type)
105
+ }
106
+
107
+ hooks.push(hook)
108
+ return hook
54
109
  }
55
110
 
56
111
  /**
@@ -61,22 +116,11 @@ export function injectBridgeEvent<T>(type: BridgeEvent, hook: BridgeHook<T>) {
61
116
  */
62
117
  export const createBridgeHook = <T>(type: BridgeEvent) => (hook: BridgeHook<T>) => injectBridgeEvent<T>(type, hook)
63
118
 
64
- /**
65
- * Hook for when the core box input changes.
66
- * The hook receives the new input value as a string.
67
- * @param data The input change data (string).
68
- */
69
- export const onCoreBoxInputChange = createBridgeHook<{ query: { inputs: Array<any>, text: string } }>(BridgeEventForCoreBox.CORE_BOX_INPUT_CHANGE)
70
-
71
- export const onCoreBoxClipboardChange = createBridgeHook<{ item: any }>(BridgeEventForCoreBox.CORE_BOX_CLIPBOARD_CHANGE)
119
+ export interface CoreBoxInputData {
120
+ query: { inputs: Array<any>, text: string }
121
+ }
72
122
 
73
- /**
74
- * Hook for when a keyboard event is forwarded from CoreBox.
75
- * This is triggered when the plugin's UI view is attached and the user
76
- * presses certain keys (Enter, Arrow keys, Meta+key combinations).
77
- * @param data The forwarded keyboard event data.
78
- */
79
- export const onCoreBoxKeyEvent = createBridgeHook<{
123
+ export interface CoreBoxKeyEventData {
80
124
  key: string
81
125
  code: string
82
126
  metaKey: boolean
@@ -84,4 +128,17 @@ export const onCoreBoxKeyEvent = createBridgeHook<{
84
128
  altKey: boolean
85
129
  shiftKey: boolean
86
130
  repeat: boolean
87
- }>(BridgeEventForCoreBox.CORE_BOX_KEY_EVENT)
131
+ }
132
+
133
+ export interface CoreBoxClipboardData {
134
+ item: any
135
+ }
136
+
137
+ /** Hook for CoreBox input changes. Payload includes `data` and `meta` (timestamp, fromCache). */
138
+ export const onCoreBoxInputChange = createBridgeHook<CoreBoxInputData>(BridgeEventForCoreBox.CORE_BOX_INPUT_CHANGE)
139
+
140
+ /** Hook for CoreBox clipboard changes. Payload includes `data` and `meta` (timestamp, fromCache). */
141
+ export const onCoreBoxClipboardChange = createBridgeHook<CoreBoxClipboardData>(BridgeEventForCoreBox.CORE_BOX_CLIPBOARD_CHANGE)
142
+
143
+ /** Hook for keyboard events forwarded from CoreBox. Payload includes `data` and `meta` (timestamp, fromCache). */
144
+ export const onCoreBoxKeyEvent = createBridgeHook<CoreBoxKeyEventData>(BridgeEventForCoreBox.CORE_BOX_KEY_EVENT)
@@ -5,24 +5,9 @@
5
5
  * and storage statistics.
6
6
  */
7
7
  import type { ITouchClientChannel } from '@talex-touch/utils/channel'
8
+ import type { StorageStats } from '../../types/storage'
8
9
  import { ensureRendererChannel } from './channel'
9
10
 
10
- /**
11
- * Storage statistics interface
12
- */
13
- export interface StorageStats {
14
- /** Total size in bytes */
15
- totalSize: number
16
- /** Number of files */
17
- fileCount: number
18
- /** Number of directories */
19
- dirCount: number
20
- /** Maximum allowed size in bytes */
21
- maxSize: number
22
- /** Usage percentage (0-100) */
23
- usagePercent: number
24
- }
25
-
26
11
  /**
27
12
  * Performance metrics interface
28
13
  */
@@ -5,6 +5,8 @@
5
5
  export interface IArgMapperOptions {
6
6
  /** The type of touch window - either main window or core-box popup */
7
7
  touchType?: 'main' | 'core-box'
8
+ /** The sub-type for core-box windows (e.g., division-box) */
9
+ coreType?: 'division-box'
8
10
  /** User data directory path */
9
11
  userDataDir?: string
10
12
  /** Application path */
@@ -32,22 +34,17 @@ declare global {
32
34
  * @returns Mapped command line arguments as key-value pairs
33
35
  */
34
36
  export function useArgMapper(args: string[] = process.argv): IArgMapperOptions {
35
- if (window.$argMapper) {
36
- return window.$argMapper
37
- }
37
+ if (window.$argMapper) return window.$argMapper
38
38
 
39
39
  const mapper: IArgMapperOptions = {}
40
-
41
40
  for (const arg of args) {
42
41
  if (arg.startsWith('--') && arg.includes('=')) {
43
42
  const [key, ...valueParts] = arg.slice(2).split('=')
44
43
  const value = valueParts.join('=')
45
-
46
44
  const camelCaseKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
47
45
  mapper[camelCaseKey] = value
48
46
  }
49
47
  }
50
-
51
48
  return window.$argMapper = mapper
52
49
  }
53
50
 
@@ -76,3 +73,20 @@ export function isMainWindow() {
76
73
  export function isCoreBox() {
77
74
  return useTouchType() === 'core-box'
78
75
  }
76
+
77
+ /**
78
+ * Gets the core-box sub-type from command line arguments
79
+ * @returns The core type ('division-box') or undefined
80
+ */
81
+ export function useCoreType() {
82
+ const argMapper = useArgMapper()
83
+ return argMapper.coreType
84
+ }
85
+
86
+ /**
87
+ * Checks if the current window is a division-box window
88
+ * @returns True if the current window is a division-box
89
+ */
90
+ export function isDivisionBox() {
91
+ return isCoreBox() && useCoreType() === 'division-box'
92
+ }