@talex-touch/utils 1.0.40 → 1.0.44
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/.eslintcache +1 -0
- package/__tests__/cloud-sync-sdk.test.ts +442 -0
- package/__tests__/icons/icons.test.ts +84 -0
- package/__tests__/plugin-sdk-lifecycle.test.ts +130 -0
- package/__tests__/power-sdk.test.ts +143 -0
- package/__tests__/preset-export-types.test.ts +108 -0
- package/__tests__/search/fuzzy-match.test.ts +137 -0
- package/__tests__/transport/port-policy.test.ts +44 -0
- package/__tests__/transport-domain-sdks.test.ts +152 -0
- package/__tests__/types/update.test.ts +67 -0
- package/account/account-sdk.ts +915 -0
- package/account/index.ts +2 -0
- package/account/types.ts +321 -0
- package/analytics/client.ts +136 -0
- package/analytics/index.ts +2 -0
- package/analytics/types.ts +156 -0
- package/animation/auto-resize.ts +322 -0
- package/animation/window-node.ts +26 -19
- package/auth/clerk-types.ts +12 -30
- package/auth/index.ts +0 -2
- package/auth/useAuthState.ts +6 -14
- package/base/index.ts +2 -0
- package/base/log-level.ts +105 -0
- package/channel/index.ts +170 -69
- package/cloud-sync/cloud-sync-sdk.ts +450 -0
- package/cloud-sync/index.ts +1 -0
- package/common/file-scan-utils.ts +17 -9
- package/common/index.ts +4 -0
- package/common/logger/index.ts +46 -0
- package/common/logger/logger-manager.ts +303 -0
- package/common/logger/module-logger.ts +270 -0
- package/common/logger/transport-logger.ts +234 -0
- package/common/logger/types.ts +93 -0
- package/common/search/gather.ts +48 -6
- package/common/search/index.ts +8 -0
- package/common/storage/constants.ts +13 -0
- package/common/storage/entity/app-settings.ts +245 -0
- package/common/storage/entity/index.ts +3 -0
- package/common/storage/entity/layout-atom-types.ts +147 -0
- package/common/storage/entity/openers.ts +1 -0
- package/common/storage/entity/preset-cloud-api.ts +132 -0
- package/common/storage/entity/preset-export-types.ts +256 -0
- package/common/storage/entity/shortcut-settings.ts +1 -0
- package/common/storage/shortcut-storage.ts +11 -0
- package/common/utils/clone-diagnostics.ts +105 -0
- package/common/utils/file.ts +16 -8
- package/common/utils/index.ts +6 -2
- package/common/utils/payload-preview.ts +173 -0
- package/common/utils/polling.ts +167 -13
- package/common/utils/safe-path.ts +103 -0
- package/common/utils/safe-shell.ts +115 -0
- package/common/utils/task-queue.ts +4 -1
- package/core-box/builder/tuff-builder.ts +0 -1
- package/core-box/index.ts +1 -1
- package/core-box/recommendation.ts +38 -1
- package/core-box/tuff/tuff-dsl.ts +97 -0
- package/electron/download-manager.ts +10 -7
- package/electron/env-tool.ts +42 -40
- package/electron/index.ts +0 -1
- package/env/index.ts +156 -0
- package/eslint.config.js +55 -0
- package/i18n/index.ts +62 -0
- package/i18n/locales/en.json +226 -0
- package/i18n/locales/zh.json +226 -0
- package/i18n/message-keys.ts +236 -0
- package/i18n/resolver.ts +181 -0
- package/icons/index.ts +257 -0
- package/icons/svg.ts +69 -0
- package/index.ts +9 -1
- package/intelligence/client.ts +72 -42
- package/market/constants.ts +21 -3
- package/market/index.ts +1 -1
- package/market/types.ts +20 -5
- package/package.json +15 -5
- package/permission/index.ts +143 -46
- package/permission/legacy.ts +26 -0
- package/permission/registry.ts +304 -0
- package/permission/types.ts +164 -0
- package/plugin/channel.ts +68 -39
- package/plugin/index.ts +82 -8
- package/plugin/install.ts +3 -0
- package/plugin/log/types.ts +22 -5
- package/plugin/node/logger-manager.ts +11 -3
- package/plugin/node/logger.ts +24 -17
- package/plugin/preload.ts +25 -2
- package/plugin/providers/index.ts +4 -0
- package/plugin/providers/market-client.ts +218 -0
- package/plugin/providers/npm-provider.ts +228 -0
- package/plugin/providers/tpex-provider.ts +297 -0
- package/plugin/providers/tpex-types.ts +34 -0
- package/plugin/sdk/box-items.ts +14 -0
- package/plugin/sdk/box-sdk.ts +64 -0
- package/plugin/sdk/channel.ts +119 -4
- package/plugin/sdk/clipboard.ts +26 -12
- package/plugin/sdk/cloud-sync.ts +113 -0
- package/plugin/sdk/common.ts +19 -11
- package/plugin/sdk/core-box.ts +6 -15
- package/plugin/sdk/division-box.ts +160 -65
- package/plugin/sdk/examples/storage-onDidChange-example.js +5 -2
- package/plugin/sdk/feature-sdk.ts +111 -76
- package/plugin/sdk/flow.ts +146 -45
- package/plugin/sdk/hooks/bridge.ts +113 -49
- package/plugin/sdk/hooks/life-cycle.ts +35 -16
- package/plugin/sdk/index.ts +14 -3
- package/plugin/sdk/intelligence.ts +87 -0
- package/plugin/sdk/meta/README.md +179 -0
- package/plugin/sdk/meta-sdk.ts +244 -0
- package/plugin/sdk/notification.ts +9 -0
- package/plugin/sdk/performance.ts +1 -16
- package/plugin/sdk/plugin-info.ts +64 -0
- package/plugin/sdk/power.ts +155 -0
- package/plugin/sdk/recommend.ts +21 -0
- package/plugin/sdk/service/index.ts +12 -8
- package/plugin/sdk/sqlite.ts +141 -0
- package/plugin/sdk/storage.ts +2 -6
- package/plugin/sdk/system.ts +2 -9
- package/plugin/sdk/temp-files.ts +41 -0
- package/plugin/sdk/touch-sdk.ts +18 -0
- package/plugin/sdk/types.ts +44 -4
- package/plugin/sdk/window/index.ts +12 -9
- package/plugin/sdk-version.ts +231 -0
- package/preload/renderer.ts +3 -2
- package/renderer/hooks/arg-mapper.ts +34 -6
- package/renderer/hooks/index.ts +13 -0
- package/renderer/hooks/initialize.ts +2 -1
- package/renderer/hooks/use-agent-market-sdk.ts +7 -0
- package/renderer/hooks/use-agent-market.ts +106 -0
- package/renderer/hooks/use-agents-sdk.ts +7 -0
- package/renderer/hooks/use-app-sdk.ts +7 -0
- package/renderer/hooks/use-channel.ts +33 -4
- package/renderer/hooks/use-download-sdk.ts +21 -0
- package/renderer/hooks/use-intelligence-sdk.ts +7 -0
- package/renderer/hooks/use-intelligence-stats.ts +290 -0
- package/renderer/hooks/use-intelligence.ts +202 -104
- package/renderer/hooks/use-market-sdk.ts +16 -0
- package/renderer/hooks/use-notification-sdk.ts +7 -0
- package/renderer/hooks/use-permission-sdk.ts +7 -0
- package/renderer/hooks/use-permission.ts +325 -0
- package/renderer/hooks/use-platform-sdk.ts +7 -0
- package/renderer/hooks/use-plugin-sdk.ts +16 -0
- package/renderer/hooks/use-settings-sdk.ts +7 -0
- package/renderer/hooks/use-update-sdk.ts +21 -0
- package/renderer/index.ts +1 -0
- package/renderer/ref.ts +19 -10
- package/renderer/shared/components/SharedPluginDetailContent.vue +84 -0
- package/renderer/shared/components/SharedPluginDetailHeader.vue +116 -0
- package/renderer/shared/components/SharedPluginDetailMetaList.vue +39 -0
- package/renderer/shared/components/SharedPluginDetailReadme.vue +45 -0
- package/renderer/shared/components/SharedPluginDetailVersions.vue +98 -0
- package/renderer/shared/components/index.ts +5 -0
- package/renderer/shared/components/shims-vue.d.ts +5 -0
- package/renderer/shared/index.ts +2 -0
- package/renderer/shared/plugin-detail.ts +62 -0
- package/renderer/storage/app-settings.ts +3 -1
- package/renderer/storage/base-storage.ts +508 -82
- package/renderer/storage/intelligence-storage.ts +37 -46
- package/renderer/storage/openers.ts +3 -1
- package/renderer/storage/storage-subscription.ts +126 -42
- package/renderer/touch-sdk/env.ts +10 -10
- package/renderer/touch-sdk/index.ts +114 -18
- package/renderer/touch-sdk/terminal.ts +24 -13
- package/search/feature-matcher.ts +279 -0
- package/search/fuzzy-match.ts +64 -34
- package/search/index.ts +10 -0
- package/search/levenshtein-utils.ts +17 -11
- package/transport/errors.ts +310 -0
- package/transport/event/builder.ts +378 -0
- package/transport/event/index.ts +7 -0
- package/transport/event/types.ts +292 -0
- package/transport/events/index.ts +2670 -0
- package/transport/events/meta-overlay.ts +79 -0
- package/transport/events/types/agents.ts +177 -0
- package/transport/events/types/app-index.ts +9 -0
- package/transport/events/types/app.ts +475 -0
- package/transport/events/types/box-item.ts +222 -0
- package/transport/events/types/clipboard.ts +80 -0
- package/transport/events/types/core-box.ts +534 -0
- package/transport/events/types/device-idle.ts +7 -0
- package/transport/events/types/division-box.ts +99 -0
- package/transport/events/types/download.ts +115 -0
- package/transport/events/types/file-index.ts +73 -0
- package/transport/events/types/flow.ts +149 -0
- package/transport/events/types/index.ts +70 -0
- package/transport/events/types/market.ts +39 -0
- package/transport/events/types/meta-overlay.ts +184 -0
- package/transport/events/types/notification.ts +140 -0
- package/transport/events/types/permission.ts +90 -0
- package/transport/events/types/platform.ts +8 -0
- package/transport/events/types/plugin.ts +620 -0
- package/transport/events/types/sentry.ts +20 -0
- package/transport/events/types/storage.ts +208 -0
- package/transport/events/types/transport.ts +60 -0
- package/transport/events/types/tray.ts +16 -0
- package/transport/events/types/update.ts +78 -0
- package/transport/index.ts +139 -0
- package/transport/main.ts +2 -0
- package/transport/sdk/constants.ts +29 -0
- package/transport/sdk/domains/agents-market.ts +47 -0
- package/transport/sdk/domains/agents.ts +62 -0
- package/transport/sdk/domains/app.ts +48 -0
- package/transport/sdk/domains/disposable.ts +35 -0
- package/transport/sdk/domains/download.ts +139 -0
- package/transport/sdk/domains/index.ts +13 -0
- package/transport/sdk/domains/intelligence.ts +616 -0
- package/transport/sdk/domains/market.ts +35 -0
- package/transport/sdk/domains/notification.ts +62 -0
- package/transport/sdk/domains/permission.ts +85 -0
- package/transport/sdk/domains/platform.ts +19 -0
- package/transport/sdk/domains/plugin.ts +144 -0
- package/transport/sdk/domains/settings.ts +92 -0
- package/transport/sdk/domains/update.ts +64 -0
- package/transport/sdk/index.ts +60 -0
- package/transport/sdk/main-transport.ts +710 -0
- package/transport/sdk/main.ts +9 -0
- package/transport/sdk/plugin-transport.ts +654 -0
- package/transport/sdk/port-policy.ts +38 -0
- package/transport/sdk/renderer-transport.ts +1165 -0
- package/transport/types.ts +605 -0
- package/types/agent.ts +399 -0
- package/types/cloud-sync.ts +157 -0
- package/types/division-box.ts +47 -27
- package/types/download.ts +1 -0
- package/types/flow.ts +63 -12
- package/types/icon.ts +2 -1
- package/types/index.ts +5 -0
- package/types/intelligence.ts +1492 -81
- package/types/modules/base.ts +2 -0
- package/types/path-browserify.d.ts +5 -0
- package/types/platform.ts +12 -0
- package/types/startup-info.ts +32 -0
- package/types/touch-app-core.ts +8 -8
- package/types/update.ts +94 -1
- package/vitest.config.ts +25 -0
- package/auth/useClerkConfig.ts +0 -40
- package/auth/useClerkProvider.ts +0 -52
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Renderer-side TuffTransport implementation
|
|
3
|
+
* @module @talex-touch/utils/transport/sdk/renderer-transport
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TuffEvent } from '../event/types'
|
|
7
|
+
import type {
|
|
8
|
+
TransportPortConfirmPayload,
|
|
9
|
+
TransportPortEnvelope,
|
|
10
|
+
TransportPortUpgradeRequest,
|
|
11
|
+
TransportPortUpgradeResponse,
|
|
12
|
+
} from '../events'
|
|
13
|
+
import type {
|
|
14
|
+
ITuffTransport,
|
|
15
|
+
SendOptions,
|
|
16
|
+
StreamController,
|
|
17
|
+
StreamMessage,
|
|
18
|
+
StreamOptions,
|
|
19
|
+
TransportPortHandle,
|
|
20
|
+
TransportPortOpenOptions,
|
|
21
|
+
} from '../types'
|
|
22
|
+
import { useChannel } from '../../renderer/hooks/use-channel'
|
|
23
|
+
import { findCloneIssue, isCloneError, summarizeClonePayload } from '../../common/utils/clone-diagnostics'
|
|
24
|
+
import { assertTuffEvent } from '../event/builder'
|
|
25
|
+
import { TransportEvents } from '../events'
|
|
26
|
+
import { STREAM_SUFFIXES } from './constants'
|
|
27
|
+
import { isPortChannelEnabled } from './port-policy'
|
|
28
|
+
|
|
29
|
+
interface CacheEntry {
|
|
30
|
+
value: unknown
|
|
31
|
+
expiresAt?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PortEventSubscription {
|
|
35
|
+
refCount: number
|
|
36
|
+
handle: TransportPortHandle | null
|
|
37
|
+
cleanup: (() => void) | null
|
|
38
|
+
opening: Promise<TransportPortHandle | null> | null
|
|
39
|
+
closing: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CacheConfig {
|
|
43
|
+
key?: string
|
|
44
|
+
mode: 'prefer' | 'only'
|
|
45
|
+
ttlMs?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeCacheOptions(options?: SendOptions): CacheConfig | null {
|
|
49
|
+
if (!options?.cache) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (options.cache === true) {
|
|
54
|
+
return { mode: 'prefer' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mode = options.cache.mode ?? 'prefer'
|
|
58
|
+
return {
|
|
59
|
+
key: options.cache.key,
|
|
60
|
+
mode,
|
|
61
|
+
ttlMs: options.cache.ttlMs,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildCacheKey(eventName: string, payload: unknown, overrideKey?: string): string {
|
|
66
|
+
if (overrideKey) {
|
|
67
|
+
return overrideKey
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (payload === undefined) {
|
|
71
|
+
return `${eventName}:__void__`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
return `${eventName}:${JSON.stringify(payload)}`
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return `${eventName}:${Object.prototype.toString.call(payload)}`
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
function resolveInvokeSender(): ((eventName: string, payload?: unknown) => Promise<unknown>) | null {
|
|
84
|
+
if (typeof globalThis === 'undefined') {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const electron = (globalThis as any).electron ?? (globalThis as any).window?.electron
|
|
89
|
+
const invoke = electron?.ipcRenderer?.invoke
|
|
90
|
+
if (typeof invoke !== 'function') {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return invoke.bind(electron.ipcRenderer)
|
|
95
|
+
}
|
|
96
|
+
interface IpcRendererLike {
|
|
97
|
+
on?: (channel: string, listener: (event: any, ...args: any[]) => void) => void
|
|
98
|
+
removeListener?: (channel: string, listener: (event: any, ...args: any[]) => void) => void
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface PortConfirmRecord {
|
|
102
|
+
port: MessagePort
|
|
103
|
+
payload: TransportPortConfirmPayload
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const PORT_CONFIRM_TIMEOUT_MS = 10000
|
|
107
|
+
const STREAM_PORT_TIMEOUT_MS = 1500
|
|
108
|
+
|
|
109
|
+
function resolveIpcRenderer(): IpcRendererLike | null {
|
|
110
|
+
if (typeof globalThis === 'undefined') {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const electron = (globalThis as any).electron ?? (globalThis as any).window?.electron
|
|
115
|
+
const ipcRenderer = electron?.ipcRenderer
|
|
116
|
+
if (!ipcRenderer) {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return ipcRenderer as IpcRendererLike
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface BatchEntry<TRes> {
|
|
124
|
+
key: string
|
|
125
|
+
payload: unknown
|
|
126
|
+
resolvers: Array<{
|
|
127
|
+
resolve: (value: TRes) => void
|
|
128
|
+
reject: (error: unknown) => void
|
|
129
|
+
}>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface BatchQueue<TRes> {
|
|
133
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
134
|
+
mergeStrategy: 'queue' | 'dedupe' | 'latest'
|
|
135
|
+
windowMs: number
|
|
136
|
+
maxSize: number
|
|
137
|
+
queue: BatchEntry<TRes>[]
|
|
138
|
+
dedupe: Map<string, BatchEntry<TRes>>
|
|
139
|
+
latest: BatchEntry<TRes> | null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Renderer-side transport implementation.
|
|
144
|
+
* Adapts the legacy TouchChannel to the new TuffTransport interface.
|
|
145
|
+
*/
|
|
146
|
+
export class TuffRendererTransport implements ITuffTransport {
|
|
147
|
+
private _channel: ReturnType<typeof useChannel> | null = null
|
|
148
|
+
private invokeSender: ((eventName: string, payload?: unknown) => Promise<unknown>) | null = null
|
|
149
|
+
private cache = new Map<string, CacheEntry>()
|
|
150
|
+
private handlers = new Map<string, Set<(payload: any) => any>>()
|
|
151
|
+
private streamControllers = new Map<string, StreamController>()
|
|
152
|
+
private batchQueues = new Map<string, BatchQueue<any>>()
|
|
153
|
+
private portCache = new Map<string, TransportPortHandle>()
|
|
154
|
+
private portEventSubscriptions = new Map<string, PortEventSubscription>()
|
|
155
|
+
private portHandlesById = new Map<string, TransportPortHandle>()
|
|
156
|
+
private pendingPortConfirms = new Map<string, { resolve: (record: PortConfirmRecord) => void, timeout?: ReturnType<typeof setTimeout> }>()
|
|
157
|
+
private queuedPortConfirms = new Map<string, PortConfirmRecord>()
|
|
158
|
+
private abandonedPorts = new Set<string>()
|
|
159
|
+
private portListenerCleanup: (() => void) | null = null
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get the channel instance, initializing it lazily on first access.
|
|
163
|
+
* This ensures TouchChannel is available when accessed.
|
|
164
|
+
*/
|
|
165
|
+
private get channel(): ReturnType<typeof useChannel> {
|
|
166
|
+
if (!this._channel) {
|
|
167
|
+
this._channel = useChannel()
|
|
168
|
+
}
|
|
169
|
+
return this._channel
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private getInvokeSender(): ((eventName: string, payload?: unknown) => Promise<unknown>) | null {
|
|
173
|
+
if (!this.invokeSender) {
|
|
174
|
+
this.invokeSender = resolveInvokeSender()
|
|
175
|
+
}
|
|
176
|
+
return this.invokeSender
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private readCache<T>(cacheKey: string): { hit: boolean, value?: T } {
|
|
180
|
+
const entry = this.cache.get(cacheKey)
|
|
181
|
+
if (!entry) {
|
|
182
|
+
return { hit: false }
|
|
183
|
+
}
|
|
184
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
|
|
185
|
+
this.cache.delete(cacheKey)
|
|
186
|
+
return { hit: false }
|
|
187
|
+
}
|
|
188
|
+
return { hit: true, value: entry.value as T }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private writeCache(cacheKey: string, value: unknown, ttlMs?: number): void {
|
|
192
|
+
const expiresAt = typeof ttlMs === 'number' && Number.isFinite(ttlMs)
|
|
193
|
+
? Date.now() + Math.max(0, ttlMs)
|
|
194
|
+
: undefined
|
|
195
|
+
this.cache.set(cacheKey, { value, expiresAt })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async sendRaw<TReq, TRes>(eventName: string, payload?: TReq | void): Promise<TRes> {
|
|
199
|
+
const invoke = this.getInvokeSender()
|
|
200
|
+
if (invoke) {
|
|
201
|
+
if (payload !== undefined) {
|
|
202
|
+
return await invoke(eventName, payload) as TRes
|
|
203
|
+
}
|
|
204
|
+
return await invoke(eventName) as TRes
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const shouldPassPayload = payload !== undefined
|
|
208
|
+
return await this.channel.send(eventName, shouldPassPayload ? payload : undefined)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private unwrapChannelPayload<T>(data: unknown): T {
|
|
212
|
+
if (!data || typeof data !== 'object')
|
|
213
|
+
return data as T
|
|
214
|
+
|
|
215
|
+
const record = data as Record<string, unknown>
|
|
216
|
+
if ('data' in record && 'header' in record)
|
|
217
|
+
return record.data as T
|
|
218
|
+
|
|
219
|
+
return data as T
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Sends a request and waits for response.
|
|
224
|
+
*/
|
|
225
|
+
async send<TReq, TRes>(
|
|
226
|
+
event: TuffEvent<TReq, TRes>,
|
|
227
|
+
payload: TReq,
|
|
228
|
+
options?: SendOptions,
|
|
229
|
+
): Promise<TRes>
|
|
230
|
+
async send<TRes>(
|
|
231
|
+
event: TuffEvent<void, TRes>,
|
|
232
|
+
payload?: void,
|
|
233
|
+
options?: SendOptions,
|
|
234
|
+
): Promise<TRes>
|
|
235
|
+
async send<TReq, TRes>(
|
|
236
|
+
event: TuffEvent<TReq, TRes> | TuffEvent<void, TRes>,
|
|
237
|
+
payload?: TReq | void,
|
|
238
|
+
options?: SendOptions,
|
|
239
|
+
): Promise<TRes> {
|
|
240
|
+
assertTuffEvent(event, 'TuffRendererTransport.send')
|
|
241
|
+
|
|
242
|
+
const eventName = event.toEventName()
|
|
243
|
+
const cacheConfig = normalizeCacheOptions(options)
|
|
244
|
+
const cacheKey = cacheConfig ? buildCacheKey(eventName, payload, cacheConfig.key) : ''
|
|
245
|
+
if (cacheConfig) {
|
|
246
|
+
const cached = this.readCache<TRes>(cacheKey)
|
|
247
|
+
if (cached.hit) {
|
|
248
|
+
return cached.value as TRes
|
|
249
|
+
}
|
|
250
|
+
if (cacheConfig.mode === 'only') {
|
|
251
|
+
throw new Error(`[TuffTransport] Cache miss for "${eventName}"`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const isImmediate = options?.immediate === true || Boolean(cacheConfig)
|
|
256
|
+
|
|
257
|
+
const batch = event._batch
|
|
258
|
+
if (!isImmediate && batch?.enabled === true) {
|
|
259
|
+
return this.enqueueBatch(eventName, batch, payload)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const shouldPassPayload = payload !== undefined
|
|
264
|
+
const result = await this.sendRaw<TReq, TRes>(eventName, shouldPassPayload ? payload : undefined)
|
|
265
|
+
if (cacheConfig) {
|
|
266
|
+
this.writeCache(cacheKey, result, cacheConfig.ttlMs)
|
|
267
|
+
}
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
272
|
+
if (isCloneError(error)) {
|
|
273
|
+
const issue = findCloneIssue(payload)
|
|
274
|
+
const summary = summarizeClonePayload(payload)
|
|
275
|
+
console.error(`[TuffTransport] Payload not cloneable for "${eventName}"`, {
|
|
276
|
+
issue,
|
|
277
|
+
summary
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`[TuffTransport] Failed to send \"${eventName}\": ${errorMessage}`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private enqueueBatch<TReq, TRes>(
|
|
285
|
+
eventName: string,
|
|
286
|
+
batch: NonNullable<TuffEvent<TReq, TRes>['_batch']>,
|
|
287
|
+
payload?: TReq | void,
|
|
288
|
+
): Promise<TRes> {
|
|
289
|
+
const windowMs = Math.max(0, Number(batch.windowMs ?? 50))
|
|
290
|
+
const maxSize = Math.max(1, Number(batch.maxSize ?? 50))
|
|
291
|
+
const mergeStrategy = (batch.mergeStrategy ?? 'queue') as BatchQueue<TRes>['mergeStrategy']
|
|
292
|
+
|
|
293
|
+
let queue = this.batchQueues.get(eventName) as BatchQueue<TRes> | undefined
|
|
294
|
+
if (!queue) {
|
|
295
|
+
queue = {
|
|
296
|
+
timer: null,
|
|
297
|
+
mergeStrategy,
|
|
298
|
+
windowMs,
|
|
299
|
+
maxSize,
|
|
300
|
+
queue: [],
|
|
301
|
+
dedupe: new Map(),
|
|
302
|
+
latest: null,
|
|
303
|
+
}
|
|
304
|
+
this.batchQueues.set(eventName, queue)
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
queue.mergeStrategy = mergeStrategy
|
|
308
|
+
queue.windowMs = windowMs
|
|
309
|
+
queue.maxSize = maxSize
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const key = this.buildBatchKey(payload)
|
|
313
|
+
|
|
314
|
+
const promise = new Promise<TRes>((resolve, reject) => {
|
|
315
|
+
const entryResolver = { resolve, reject }
|
|
316
|
+
|
|
317
|
+
if (queue!.mergeStrategy === 'latest') {
|
|
318
|
+
if (!queue!.latest) {
|
|
319
|
+
queue!.latest = { key: '__latest__', payload, resolvers: [entryResolver] }
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
queue!.latest.payload = payload
|
|
323
|
+
queue!.latest.resolvers.push(entryResolver)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else if (queue!.mergeStrategy === 'dedupe') {
|
|
327
|
+
const existing = queue!.dedupe.get(key)
|
|
328
|
+
if (existing) {
|
|
329
|
+
existing.resolvers.push(entryResolver)
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
queue!.dedupe.set(key, { key, payload, resolvers: [entryResolver] })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
queue!.queue.push({ key, payload, resolvers: [entryResolver] })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const size = queue!.mergeStrategy === 'latest'
|
|
340
|
+
? (queue!.latest ? 1 : 0)
|
|
341
|
+
: queue!.mergeStrategy === 'dedupe'
|
|
342
|
+
? queue!.dedupe.size
|
|
343
|
+
: queue!.queue.length
|
|
344
|
+
|
|
345
|
+
if (size >= queue!.maxSize) {
|
|
346
|
+
void this.flushEvent(eventName)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!queue!.timer) {
|
|
351
|
+
queue!.timer = setTimeout(() => {
|
|
352
|
+
queue!.timer = null
|
|
353
|
+
void this.flushEvent(eventName)
|
|
354
|
+
}, queue!.windowMs)
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
return promise
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private buildBatchKey(payload: unknown): string {
|
|
362
|
+
if (payload === undefined)
|
|
363
|
+
return '__void__'
|
|
364
|
+
if (payload === null)
|
|
365
|
+
return '__null__'
|
|
366
|
+
if (typeof payload === 'string')
|
|
367
|
+
return `str:${payload}`
|
|
368
|
+
try {
|
|
369
|
+
return `json:${JSON.stringify(payload)}`
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return `ref:${Object.prototype.toString.call(payload)}`
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async flushEvent(eventName: string): Promise<void> {
|
|
377
|
+
const queue = this.batchQueues.get(eventName)
|
|
378
|
+
if (!queue)
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
if (queue.timer) {
|
|
382
|
+
clearTimeout(queue.timer)
|
|
383
|
+
queue.timer = null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.batchQueues.delete(eventName)
|
|
387
|
+
|
|
388
|
+
if (queue.mergeStrategy === 'latest') {
|
|
389
|
+
const entry = queue.latest
|
|
390
|
+
if (!entry)
|
|
391
|
+
return
|
|
392
|
+
await this.flushEntry(eventName, entry)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (queue.mergeStrategy === 'dedupe') {
|
|
397
|
+
const entries = Array.from(queue.dedupe.values())
|
|
398
|
+
await Promise.all(entries.map(entry => this.flushEntry(eventName, entry)))
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await queue.queue.reduce<Promise<void>>(
|
|
403
|
+
(promise, entry) => promise.then(() => this.flushEntry(eventName, entry)),
|
|
404
|
+
Promise.resolve(),
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private formatPortErrorMessage(error?: unknown): string | null {
|
|
409
|
+
if (!error)
|
|
410
|
+
return null
|
|
411
|
+
const raw = error instanceof Error ? error.message : String(error)
|
|
412
|
+
if (!raw)
|
|
413
|
+
return null
|
|
414
|
+
const normalized = raw.replace(/\s+/g, ' ').trim()
|
|
415
|
+
if (!normalized)
|
|
416
|
+
return null
|
|
417
|
+
return normalized.length > 200 ? `${normalized.slice(0, 200)}...` : normalized
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private logPortIssue(channel: string, reason: string, error?: unknown): void {
|
|
421
|
+
const channelName = channel.trim() || 'unknown'
|
|
422
|
+
const detail = this.formatPortErrorMessage(error)
|
|
423
|
+
const suffix = detail ? `: ${detail}` : ''
|
|
424
|
+
console.warn(`[TuffTransport] Port issue for "${channelName}": ${reason}${suffix}`)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private logPortFallback(channel: string, reason: string, error?: unknown): void {
|
|
428
|
+
const channelName = channel.trim() || 'unknown'
|
|
429
|
+
const detail = this.formatPortErrorMessage(error)
|
|
430
|
+
const suffix = detail ? `: ${detail}` : ''
|
|
431
|
+
console.warn(`[TuffTransport] Port fallback for "${channelName}": ${reason}${suffix}`)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private ensurePortListener(): boolean {
|
|
435
|
+
if (this.portListenerCleanup) {
|
|
436
|
+
return true
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const ipcRenderer = resolveIpcRenderer()
|
|
440
|
+
if (!ipcRenderer?.on || !ipcRenderer.removeListener) {
|
|
441
|
+
return false
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const eventName = TransportEvents.port.confirm.toEventName()
|
|
445
|
+
const handler = (event: any, payload: TransportPortConfirmPayload) => {
|
|
446
|
+
this.handlePortConfirm(event, payload)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
ipcRenderer.on(eventName, handler)
|
|
450
|
+
this.portListenerCleanup = () => {
|
|
451
|
+
ipcRenderer.removeListener?.(eventName, handler)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return true
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private handlePortConfirm(event: any, payload: TransportPortConfirmPayload): void {
|
|
458
|
+
const portId = payload?.portId
|
|
459
|
+
const channel = payload?.channel
|
|
460
|
+
const port = event?.ports?.[0] as MessagePort | undefined
|
|
461
|
+
if (!portId || !channel || !port) {
|
|
462
|
+
this.logPortIssue(channel ?? 'unknown', 'confirm_payload_invalid')
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (this.abandonedPorts.has(portId)) {
|
|
467
|
+
this.abandonedPorts.delete(portId)
|
|
468
|
+
try {
|
|
469
|
+
port.close()
|
|
470
|
+
}
|
|
471
|
+
catch {}
|
|
472
|
+
void this.send(TransportEvents.port.close, { channel, portId, reason: 'confirm_timeout' }).catch(() => {})
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const record: PortConfirmRecord = { port, payload }
|
|
477
|
+
const pending = this.pendingPortConfirms.get(portId)
|
|
478
|
+
if (pending) {
|
|
479
|
+
this.pendingPortConfirms.delete(portId)
|
|
480
|
+
if (pending.timeout) {
|
|
481
|
+
clearTimeout(pending.timeout)
|
|
482
|
+
}
|
|
483
|
+
pending.resolve(record)
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
this.queuedPortConfirms.set(portId, record)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
void this.send(TransportEvents.port.confirm, payload).catch(() => {})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async waitForPortConfirm(
|
|
493
|
+
portId: string,
|
|
494
|
+
channel: string,
|
|
495
|
+
timeoutMs: number,
|
|
496
|
+
): Promise<PortConfirmRecord | null> {
|
|
497
|
+
const queued = this.queuedPortConfirms.get(portId)
|
|
498
|
+
if (queued) {
|
|
499
|
+
this.queuedPortConfirms.delete(portId)
|
|
500
|
+
return queued
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (timeoutMs <= 0) {
|
|
504
|
+
return null
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return await new Promise((resolve) => {
|
|
508
|
+
const timeout = setTimeout(() => {
|
|
509
|
+
this.pendingPortConfirms.delete(portId)
|
|
510
|
+
this.abandonedPorts.add(portId)
|
|
511
|
+
void this.send(TransportEvents.port.close, { channel, portId, reason: 'confirm_timeout' }).catch(() => {})
|
|
512
|
+
resolve(null)
|
|
513
|
+
}, timeoutMs)
|
|
514
|
+
|
|
515
|
+
this.pendingPortConfirms.set(portId, { resolve, timeout })
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private buildPortHandle(record: PortConfirmRecord, cache: boolean): TransportPortHandle {
|
|
520
|
+
const { port, payload } = record
|
|
521
|
+
const portId = payload.portId
|
|
522
|
+
const channel = payload.channel
|
|
523
|
+
|
|
524
|
+
let closing = false
|
|
525
|
+
|
|
526
|
+
const handle: TransportPortHandle = {
|
|
527
|
+
portId,
|
|
528
|
+
channel,
|
|
529
|
+
port,
|
|
530
|
+
close: async (reason?: string) => {
|
|
531
|
+
closing = true
|
|
532
|
+
this.evictPortHandle(portId, channel)
|
|
533
|
+
try {
|
|
534
|
+
port.close()
|
|
535
|
+
}
|
|
536
|
+
catch {}
|
|
537
|
+
await this.send(TransportEvents.port.close, {
|
|
538
|
+
channel,
|
|
539
|
+
portId,
|
|
540
|
+
reason,
|
|
541
|
+
}).catch(() => {})
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this.portHandlesById.set(portId, handle)
|
|
546
|
+
if (cache) {
|
|
547
|
+
this.portCache.set(channel, handle)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const onClose = () => {
|
|
551
|
+
this.evictPortHandle(portId, channel)
|
|
552
|
+
if (!closing) {
|
|
553
|
+
this.logPortFallback(channel, 'port_closed')
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const onMessageError = () => {
|
|
558
|
+
this.logPortFallback(channel, 'message_error')
|
|
559
|
+
this.evictPortHandle(portId, channel)
|
|
560
|
+
void this.send(TransportEvents.port.error, {
|
|
561
|
+
channel,
|
|
562
|
+
portId,
|
|
563
|
+
error: { code: 'message_error', message: 'MessagePort messageerror' },
|
|
564
|
+
}).catch(() => {})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (typeof port.addEventListener === 'function') {
|
|
568
|
+
port.addEventListener('close', onClose)
|
|
569
|
+
port.addEventListener('messageerror', onMessageError)
|
|
570
|
+
}
|
|
571
|
+
if (typeof port.start === 'function') {
|
|
572
|
+
port.start()
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return handle
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private evictPortHandle(portId: string, channel: string): void {
|
|
579
|
+
this.portHandlesById.delete(portId)
|
|
580
|
+
const cached = this.portCache.get(channel)
|
|
581
|
+
if (cached?.portId === portId) {
|
|
582
|
+
this.portCache.delete(channel)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private normalizePortStreamMessage<TChunk>(raw: unknown): StreamMessage<TChunk> | null {
|
|
587
|
+
if (!raw || typeof raw !== 'object') {
|
|
588
|
+
return null
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const record = raw as StreamMessage<TChunk> & TransportPortEnvelope<StreamMessage<TChunk>>
|
|
592
|
+
const rawType = (record as { type?: string }).type
|
|
593
|
+
const rawStreamId = (record as { streamId?: string | number }).streamId
|
|
594
|
+
if (!rawType || rawStreamId === undefined || rawStreamId === null) {
|
|
595
|
+
return null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const streamId = String(rawStreamId)
|
|
599
|
+
|
|
600
|
+
if (rawType === 'data') {
|
|
601
|
+
const chunk = (record as { chunk?: TChunk }).chunk ?? (record as { payload?: { chunk?: TChunk } }).payload?.chunk
|
|
602
|
+
return { type: 'data', streamId, chunk }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (rawType === 'error') {
|
|
606
|
+
const errorRecord = record as { error?: { message?: string } | string, payload?: { error?: string } }
|
|
607
|
+
const errorMessage = typeof errorRecord.error === 'string'
|
|
608
|
+
? errorRecord.error
|
|
609
|
+
: errorRecord.error?.message ?? errorRecord.payload?.error
|
|
610
|
+
return { type: 'error', streamId, error: errorMessage }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (rawType === 'end' || rawType === 'close') {
|
|
614
|
+
return { type: 'end', streamId }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private normalizePortEventMessage<TReq>(raw: unknown, channel: string): TReq | null {
|
|
621
|
+
if (!raw || typeof raw !== 'object') {
|
|
622
|
+
return null
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const record = raw as TransportPortEnvelope<TReq> & { payload?: TReq }
|
|
626
|
+
if (record.channel && record.channel !== channel) {
|
|
627
|
+
return null
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (record.type && record.type !== 'data') {
|
|
631
|
+
if (record.type === 'error' && record.error?.message) {
|
|
632
|
+
this.logPortFallback(channel, 'message_error', record.error.message)
|
|
633
|
+
}
|
|
634
|
+
return null
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (record.payload !== undefined) {
|
|
638
|
+
return record.payload as TReq
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return null
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private dropPortEventSubscription(channel: string): void {
|
|
645
|
+
const subscription = this.portEventSubscriptions.get(channel)
|
|
646
|
+
if (!subscription)
|
|
647
|
+
return
|
|
648
|
+
subscription.cleanup?.()
|
|
649
|
+
subscription.cleanup = null
|
|
650
|
+
subscription.handle = null
|
|
651
|
+
subscription.opening = null
|
|
652
|
+
subscription.closing = true
|
|
653
|
+
this.portEventSubscriptions.delete(channel)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private ensurePortEventSubscription(channel: string): void {
|
|
657
|
+
if (!isPortChannelEnabled(channel)) {
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const existing = this.portEventSubscriptions.get(channel)
|
|
661
|
+
if (existing) {
|
|
662
|
+
existing.refCount += 1
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const subscription: PortEventSubscription = {
|
|
667
|
+
refCount: 1,
|
|
668
|
+
handle: null,
|
|
669
|
+
cleanup: null,
|
|
670
|
+
opening: null,
|
|
671
|
+
closing: false,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
this.portEventSubscriptions.set(channel, subscription)
|
|
675
|
+
|
|
676
|
+
subscription.opening = this.openPort({ channel })
|
|
677
|
+
.then((handle) => {
|
|
678
|
+
if (!handle) {
|
|
679
|
+
this.logPortFallback(channel, 'port_unavailable')
|
|
680
|
+
this.dropPortEventSubscription(channel)
|
|
681
|
+
return null
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (subscription.closing) {
|
|
685
|
+
void handle.close('subscription_closed')
|
|
686
|
+
this.dropPortEventSubscription(channel)
|
|
687
|
+
return null
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
subscription.handle = handle
|
|
691
|
+
const port = handle.port
|
|
692
|
+
|
|
693
|
+
const messageHandler = (event: MessageEvent) => {
|
|
694
|
+
const payload = this.normalizePortEventMessage<any>(event?.data, channel)
|
|
695
|
+
if (payload === null)
|
|
696
|
+
return
|
|
697
|
+
const handlers = this.handlers.get(channel)
|
|
698
|
+
if (!handlers || handlers.size === 0)
|
|
699
|
+
return
|
|
700
|
+
handlers.forEach((handler) => {
|
|
701
|
+
Promise.resolve(handler(payload)).catch((error) => {
|
|
702
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
703
|
+
console.error(`[TuffTransport] Handler error for \"${channel}\":`, errorMessage)
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const closeHandler = () => {
|
|
709
|
+
this.logPortFallback(channel, 'port_closed')
|
|
710
|
+
this.dropPortEventSubscription(channel)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const errorHandler = () => {
|
|
714
|
+
this.logPortFallback(channel, 'message_error')
|
|
715
|
+
this.dropPortEventSubscription(channel)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (typeof port.addEventListener === 'function') {
|
|
719
|
+
port.addEventListener('message', messageHandler)
|
|
720
|
+
port.addEventListener('messageerror', errorHandler)
|
|
721
|
+
port.addEventListener('close', closeHandler)
|
|
722
|
+
port.start?.()
|
|
723
|
+
subscription.cleanup = () => {
|
|
724
|
+
port.removeEventListener('message', messageHandler)
|
|
725
|
+
port.removeEventListener('messageerror', errorHandler)
|
|
726
|
+
port.removeEventListener('close', closeHandler)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
port.onmessage = messageHandler as any
|
|
731
|
+
subscription.cleanup = () => {
|
|
732
|
+
port.onmessage = null
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return handle
|
|
737
|
+
})
|
|
738
|
+
.catch((error) => {
|
|
739
|
+
this.logPortFallback(channel, 'open_failed', error)
|
|
740
|
+
this.dropPortEventSubscription(channel)
|
|
741
|
+
return null
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private releasePortEventSubscription(channel: string): void {
|
|
746
|
+
const subscription = this.portEventSubscriptions.get(channel)
|
|
747
|
+
if (!subscription)
|
|
748
|
+
return
|
|
749
|
+
subscription.refCount -= 1
|
|
750
|
+
if (subscription.refCount > 0)
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
subscription.closing = true
|
|
754
|
+
subscription.cleanup?.()
|
|
755
|
+
subscription.cleanup = null
|
|
756
|
+
if (subscription.handle) {
|
|
757
|
+
void subscription.handle.close('no_handlers')
|
|
758
|
+
}
|
|
759
|
+
this.portEventSubscriptions.delete(channel)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async upgrade(options: TransportPortUpgradeRequest): Promise<TransportPortUpgradeResponse> {
|
|
763
|
+
const channel = options?.channel?.trim()
|
|
764
|
+
if (!channel) {
|
|
765
|
+
return {
|
|
766
|
+
accepted: false,
|
|
767
|
+
channel: '',
|
|
768
|
+
error: { code: 'invalid_request', message: 'Channel is required' },
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return await this.send(TransportEvents.port.upgrade, {
|
|
773
|
+
channel,
|
|
774
|
+
scope: options.scope,
|
|
775
|
+
windowId: options.windowId,
|
|
776
|
+
plugin: options.plugin,
|
|
777
|
+
permissions: options.permissions,
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async openPort(options: TransportPortOpenOptions): Promise<TransportPortHandle | null> {
|
|
782
|
+
const channel = options?.channel?.trim()
|
|
783
|
+
if (!channel) {
|
|
784
|
+
this.logPortIssue('unknown', 'missing_channel')
|
|
785
|
+
return null
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
const useCache = options.force !== true
|
|
790
|
+
if (useCache) {
|
|
791
|
+
const cached = this.portCache.get(channel)
|
|
792
|
+
if (cached) {
|
|
793
|
+
return cached
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (!this.ensurePortListener()) {
|
|
798
|
+
this.logPortIssue(channel, 'ipc_unavailable')
|
|
799
|
+
return null
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const response = await this.upgrade({
|
|
803
|
+
channel,
|
|
804
|
+
scope: options.scope,
|
|
805
|
+
windowId: options.windowId,
|
|
806
|
+
plugin: options.plugin,
|
|
807
|
+
permissions: options.permissions,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
if (!response.accepted || !response.portId) {
|
|
811
|
+
if (response.error) {
|
|
812
|
+
const reason = response.error.code ? `upgrade_rejected:${response.error.code}` : 'upgrade_rejected'
|
|
813
|
+
this.logPortIssue(channel, reason, response.error.message)
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
this.logPortIssue(channel, 'upgrade_rejected')
|
|
817
|
+
}
|
|
818
|
+
return null
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs)
|
|
822
|
+
? Math.max(0, options.timeoutMs)
|
|
823
|
+
: PORT_CONFIRM_TIMEOUT_MS
|
|
824
|
+
const record = await this.waitForPortConfirm(response.portId, channel, timeoutMs)
|
|
825
|
+
if (!record) {
|
|
826
|
+
this.logPortIssue(channel, 'confirm_timeout')
|
|
827
|
+
return null
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return this.buildPortHandle(record, useCache)
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
this.logPortIssue(channel, 'open_failed', error)
|
|
834
|
+
return null
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private async flushEntry<TRes>(eventName: string, entry: BatchEntry<TRes>): Promise<void> {
|
|
839
|
+
try {
|
|
840
|
+
const shouldPassPayload = entry.payload !== undefined
|
|
841
|
+
const result = await this.sendRaw(eventName, shouldPassPayload ? entry.payload : undefined)
|
|
842
|
+
for (const { resolve } of entry.resolvers) {
|
|
843
|
+
resolve(result as TRes)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
848
|
+
const wrapped = new Error(`[TuffTransport] Failed to send \"${eventName}\": ${errorMessage}`)
|
|
849
|
+
for (const { reject } of entry.resolvers) {
|
|
850
|
+
reject(wrapped)
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Initiates a stream request.
|
|
857
|
+
*
|
|
858
|
+
* @remarks
|
|
859
|
+
* Phase 1 implementation uses IPC events to simulate streaming.
|
|
860
|
+
* Future versions will use MessagePort for true streaming.
|
|
861
|
+
*/
|
|
862
|
+
async stream<TReq, TChunk>(
|
|
863
|
+
event: TuffEvent<TReq, AsyncIterable<TChunk>>,
|
|
864
|
+
payload: TReq,
|
|
865
|
+
options: StreamOptions<TChunk>,
|
|
866
|
+
): Promise<StreamController> {
|
|
867
|
+
assertTuffEvent(event, 'TuffRendererTransport.stream')
|
|
868
|
+
|
|
869
|
+
const eventName = event.toEventName()
|
|
870
|
+
const streamId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
|
871
|
+
const streamEventName = `${eventName}${STREAM_SUFFIXES.START}`
|
|
872
|
+
|
|
873
|
+
let cancelled = false
|
|
874
|
+
let cleaned = false
|
|
875
|
+
const cleanupCallbacks: Array<() => void> = []
|
|
876
|
+
const cleanup = () => {
|
|
877
|
+
if (cleaned)
|
|
878
|
+
return
|
|
879
|
+
cleaned = true
|
|
880
|
+
cleanupCallbacks.forEach(callback => callback())
|
|
881
|
+
this.streamControllers.delete(streamId)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const portOptions = options.port === false || !isPortChannelEnabled(eventName)
|
|
885
|
+
? null
|
|
886
|
+
: {
|
|
887
|
+
channel: eventName,
|
|
888
|
+
...options.port,
|
|
889
|
+
timeoutMs: options.port?.timeoutMs ?? STREAM_PORT_TIMEOUT_MS,
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
let portHandle: TransportPortHandle | null = null
|
|
893
|
+
let portActive = false
|
|
894
|
+
|
|
895
|
+
if (portOptions) {
|
|
896
|
+
try {
|
|
897
|
+
portHandle = await this.openPort(portOptions)
|
|
898
|
+
if (!portHandle) {
|
|
899
|
+
this.logPortFallback(eventName, 'port_unavailable')
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
this.logPortFallback(eventName, 'open_failed', error)
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const fallbackToChannel = (reason: string) => {
|
|
908
|
+
if (!portHandle)
|
|
909
|
+
return
|
|
910
|
+
if (portActive) {
|
|
911
|
+
portActive = false
|
|
912
|
+
}
|
|
913
|
+
this.logPortFallback(eventName, reason)
|
|
914
|
+
void portHandle.close(reason)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (portHandle) {
|
|
918
|
+
const port = portHandle.port
|
|
919
|
+
|
|
920
|
+
const portMessageHandler = (event: MessageEvent) => {
|
|
921
|
+
if (cancelled)
|
|
922
|
+
return
|
|
923
|
+
|
|
924
|
+
const message = this.normalizePortStreamMessage<TChunk>(event?.data)
|
|
925
|
+
if (!message || message.streamId !== streamId) {
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
portActive = true
|
|
930
|
+
|
|
931
|
+
if (message.type === 'data' && message.chunk !== undefined) {
|
|
932
|
+
options.onData(message.chunk)
|
|
933
|
+
return
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (message.type === 'error') {
|
|
937
|
+
options.onError?.(new Error(message.error ?? 'Stream error'))
|
|
938
|
+
cleanup()
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (message.type === 'end') {
|
|
943
|
+
options.onEnd?.()
|
|
944
|
+
cleanup()
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const portCloseHandler = () => {
|
|
949
|
+
if (cancelled)
|
|
950
|
+
return
|
|
951
|
+
fallbackToChannel('port_closed')
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const portErrorHandler = () => {
|
|
955
|
+
if (cancelled)
|
|
956
|
+
return
|
|
957
|
+
fallbackToChannel('message_error')
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (typeof port.addEventListener === 'function') {
|
|
961
|
+
port.addEventListener('message', portMessageHandler)
|
|
962
|
+
port.addEventListener('messageerror', portErrorHandler)
|
|
963
|
+
port.addEventListener('close', portCloseHandler)
|
|
964
|
+
port.start?.()
|
|
965
|
+
cleanupCallbacks.push(() => {
|
|
966
|
+
port.removeEventListener('message', portMessageHandler)
|
|
967
|
+
port.removeEventListener('messageerror', portErrorHandler)
|
|
968
|
+
port.removeEventListener('close', portCloseHandler)
|
|
969
|
+
})
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
port.onmessage = portMessageHandler as any
|
|
973
|
+
cleanupCallbacks.push(() => {
|
|
974
|
+
port.onmessage = null
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
cleanupCallbacks.push(() => {
|
|
979
|
+
if (portOptions?.force === true) {
|
|
980
|
+
void portHandle?.close('stream_cleanup')
|
|
981
|
+
}
|
|
982
|
+
})
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Register stream data handler (channel fallback)
|
|
986
|
+
if (this.channel.regChannel) {
|
|
987
|
+
const dataEventName = `${eventName}${STREAM_SUFFIXES.DATA}:${streamId}`
|
|
988
|
+
const endEventName = `${eventName}${STREAM_SUFFIXES.END}:${streamId}`
|
|
989
|
+
const errorEventName = `${eventName}${STREAM_SUFFIXES.ERROR}:${streamId}`
|
|
990
|
+
|
|
991
|
+
const dataHandler = (raw: unknown) => {
|
|
992
|
+
if (cancelled || portActive)
|
|
993
|
+
return
|
|
994
|
+
|
|
995
|
+
const data = this.unwrapChannelPayload<{ chunk?: TChunk, error?: string }>(raw)
|
|
996
|
+
|
|
997
|
+
if (data?.error) {
|
|
998
|
+
options.onError?.(new Error(data.error))
|
|
999
|
+
return
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (data?.chunk !== undefined) {
|
|
1003
|
+
options.onData(data.chunk)
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const endHandler = () => {
|
|
1008
|
+
if (cancelled || portActive)
|
|
1009
|
+
return
|
|
1010
|
+
options.onEnd?.()
|
|
1011
|
+
cleanup()
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const errorHandler = (raw: unknown) => {
|
|
1015
|
+
if (cancelled || portActive)
|
|
1016
|
+
return
|
|
1017
|
+
const data = this.unwrapChannelPayload<{ error: string }>(raw)
|
|
1018
|
+
options.onError?.(new Error(data?.error))
|
|
1019
|
+
cleanup()
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const dataCleanup = this.channel.regChannel(dataEventName, dataHandler)
|
|
1023
|
+
const endCleanup = this.channel.regChannel(endEventName, endHandler)
|
|
1024
|
+
const errorCleanup = this.channel.regChannel(errorEventName, errorHandler)
|
|
1025
|
+
|
|
1026
|
+
cleanupCallbacks.push(() => {
|
|
1027
|
+
dataCleanup()
|
|
1028
|
+
endCleanup()
|
|
1029
|
+
errorCleanup()
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Create controller
|
|
1034
|
+
const controller: StreamController = {
|
|
1035
|
+
cancel: () => {
|
|
1036
|
+
if (cancelled)
|
|
1037
|
+
return
|
|
1038
|
+
cancelled = true
|
|
1039
|
+
this.channel.send(`${eventName}${STREAM_SUFFIXES.CANCEL}`, { streamId }).catch(() => {
|
|
1040
|
+
// Ignore cancel errors
|
|
1041
|
+
})
|
|
1042
|
+
cleanup()
|
|
1043
|
+
},
|
|
1044
|
+
get cancelled() {
|
|
1045
|
+
return cancelled
|
|
1046
|
+
},
|
|
1047
|
+
streamId,
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
this.streamControllers.set(streamId, controller)
|
|
1051
|
+
|
|
1052
|
+
// Start the stream
|
|
1053
|
+
try {
|
|
1054
|
+
// Handle void payload (undefined)
|
|
1055
|
+
const streamPayload = payload !== undefined
|
|
1056
|
+
? { streamId, ...payload }
|
|
1057
|
+
: { streamId }
|
|
1058
|
+
await this.channel.send(streamEventName, streamPayload)
|
|
1059
|
+
}
|
|
1060
|
+
catch (error) {
|
|
1061
|
+
controller.cancel()
|
|
1062
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`[TuffTransport] Failed to start stream "${eventName}": ${errorMessage}`,
|
|
1065
|
+
)
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return controller
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Registers an event handler (for receiving messages from main process).
|
|
1073
|
+
*/
|
|
1074
|
+
on<TReq, TRes>(
|
|
1075
|
+
event: TuffEvent<TReq, TRes>,
|
|
1076
|
+
handler: (payload: TReq) => TRes | Promise<TRes>,
|
|
1077
|
+
): () => void {
|
|
1078
|
+
assertTuffEvent(event, 'TuffRendererTransport.on')
|
|
1079
|
+
|
|
1080
|
+
if (!this.channel.regChannel) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
'[TuffTransport] Channel does not support event registration',
|
|
1083
|
+
)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const eventName = event.toEventName()
|
|
1087
|
+
const handlerSet = this.handlers.get(eventName) || new Set()
|
|
1088
|
+
const isFirstHandler = handlerSet.size === 0
|
|
1089
|
+
handlerSet.add(handler)
|
|
1090
|
+
this.handlers.set(eventName, handlerSet)
|
|
1091
|
+
if (isFirstHandler) {
|
|
1092
|
+
this.ensurePortEventSubscription(eventName)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Register with channel
|
|
1096
|
+
const cleanup = this.channel.regChannel(eventName, async (data: TReq) => {
|
|
1097
|
+
try {
|
|
1098
|
+
return await handler(this.unwrapChannelPayload<TReq>(data))
|
|
1099
|
+
}
|
|
1100
|
+
catch (error) {
|
|
1101
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
1102
|
+
console.error(`[TuffTransport] Handler error for "${eventName}":`, errorMessage)
|
|
1103
|
+
throw error
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
// Return combined cleanup
|
|
1108
|
+
return () => {
|
|
1109
|
+
handlerSet.delete(handler)
|
|
1110
|
+
if (handlerSet.size === 0) {
|
|
1111
|
+
this.handlers.delete(eventName)
|
|
1112
|
+
this.releasePortEventSubscription(eventName)
|
|
1113
|
+
}
|
|
1114
|
+
cleanup()
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Forces immediate flush of all pending batch requests.
|
|
1120
|
+
*
|
|
1121
|
+
* @remarks
|
|
1122
|
+
* Phase 1: No-op. Batching will be implemented in Phase 2.
|
|
1123
|
+
*/
|
|
1124
|
+
async flush(): Promise<void> {
|
|
1125
|
+
const eventNames = Array.from(this.batchQueues.keys())
|
|
1126
|
+
await Promise.all(eventNames.map(eventName => this.flushEvent(eventName)))
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Destroys the transport instance and cleans up resources.
|
|
1131
|
+
*/
|
|
1132
|
+
destroy(): void {
|
|
1133
|
+
// Cancel all active streams
|
|
1134
|
+
for (const controller of this.streamControllers.values()) {
|
|
1135
|
+
controller.cancel()
|
|
1136
|
+
}
|
|
1137
|
+
this.streamControllers.clear()
|
|
1138
|
+
|
|
1139
|
+
// Clear all handlers
|
|
1140
|
+
this.handlers.clear()
|
|
1141
|
+
|
|
1142
|
+
if (this.portListenerCleanup) {
|
|
1143
|
+
this.portListenerCleanup()
|
|
1144
|
+
this.portListenerCleanup = null
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
for (const subscription of this.portEventSubscriptions.values()) {
|
|
1148
|
+
subscription.cleanup?.()
|
|
1149
|
+
subscription.cleanup = null
|
|
1150
|
+
if (subscription.handle) {
|
|
1151
|
+
void subscription.handle.close('transport_destroy').catch(() => {})
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
this.portEventSubscriptions.clear()
|
|
1155
|
+
|
|
1156
|
+
for (const handle of this.portHandlesById.values()) {
|
|
1157
|
+
void handle.close('transport_destroy').catch(() => {})
|
|
1158
|
+
}
|
|
1159
|
+
this.portHandlesById.clear()
|
|
1160
|
+
this.portCache.clear()
|
|
1161
|
+
this.pendingPortConfirms.clear()
|
|
1162
|
+
this.queuedPortConfirms.clear()
|
|
1163
|
+
this.abandonedPorts.clear()
|
|
1164
|
+
}
|
|
1165
|
+
}
|