@talex-touch/utils 1.0.42 → 1.0.45
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 +32 -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 +9 -5
- package/market/index.ts +1 -1
- package/market/types.ts +19 -4
- 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 +80 -7
- 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 -4
- package/plugin/providers/market-client.ts +6 -3
- package/plugin/providers/npm-provider.ts +22 -7
- package/plugin/providers/tpex-provider.ts +22 -8
- 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 +13 -6
- 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/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 +16 -2
- 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 +55 -214
- 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 +31 -40
- 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 +2690 -0
- package/transport/events/meta-overlay.ts +79 -0
- package/transport/events/types/agents.ts +177 -0
- package/transport/events/types/app-index.ts +20 -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 +84 -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 +631 -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 +141 -0
- package/transport/main.ts +2 -0
- package/transport/prelude.ts +208 -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 +102 -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 +31 -31
- 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 +166 -173
- 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
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { UnwrapNestedRefs, WatchHandle } from 'vue'
|
|
2
2
|
import type { ITouchClientChannel } from '../../channel'
|
|
3
|
+
import type { ITuffTransport } from '../../transport'
|
|
4
|
+
import type {
|
|
5
|
+
StorageGetVersionedResponse,
|
|
6
|
+
StorageSaveRequest,
|
|
7
|
+
StorageSaveResult,
|
|
8
|
+
StorageUpdateNotification,
|
|
9
|
+
} from '../../transport/events/types'
|
|
3
10
|
import { useDebounceFn } from '@vueuse/core'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
watch,
|
|
8
|
-
|
|
9
|
-
} from 'vue'
|
|
11
|
+
import { reactive, ref, toRaw, watch } from 'vue'
|
|
12
|
+
import { isElectronRenderer } from '../../env'
|
|
13
|
+
import { StorageEvents } from '../../transport/events'
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Interface representing the external communication channel.
|
|
@@ -19,16 +23,16 @@ export interface IStorageChannel extends ITouchClientChannel {
|
|
|
19
23
|
* @param payload Event payload
|
|
20
24
|
*/
|
|
21
25
|
send: (event: string, payload: unknown) => Promise<unknown>
|
|
26
|
+
}
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*/
|
|
28
|
-
sendSync: (event: string, payload: unknown) => unknown
|
|
28
|
+
export type StorageInitMode = 'auto' | 'sync' | 'async'
|
|
29
|
+
|
|
30
|
+
export interface TouchStorageOptions {
|
|
31
|
+
initMode?: StorageInitMode
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
let channel: IStorageChannel | null = null
|
|
35
|
+
let transport: ITuffTransport | null = null
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
38
|
* Initializes the global channel for communication.
|
|
@@ -41,12 +45,20 @@ let channel: IStorageChannel | null = null
|
|
|
41
45
|
*
|
|
42
46
|
* initStorageChannel({
|
|
43
47
|
* send: ipcRenderer.invoke.bind(ipcRenderer),
|
|
44
|
-
* sendSync: ipcRenderer.sendSync.bind(ipcRenderer),
|
|
45
48
|
* });
|
|
46
49
|
* ```
|
|
47
50
|
*/
|
|
48
51
|
export function initStorageChannel(c: IStorageChannel): void {
|
|
49
52
|
channel = c
|
|
53
|
+
initializeStorageInstances()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Initializes the global TuffTransport for storage operations.
|
|
58
|
+
*/
|
|
59
|
+
export function initStorageTransport(t: ITuffTransport): void {
|
|
60
|
+
transport = t
|
|
61
|
+
initializeStorageInstances()
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/**
|
|
@@ -68,6 +80,12 @@ function getGlobalStorageMap(): GlobalStorageMap {
|
|
|
68
80
|
|
|
69
81
|
export const storages: GlobalStorageMap = getGlobalStorageMap()
|
|
70
82
|
|
|
83
|
+
function initializeStorageInstances(): void {
|
|
84
|
+
for (const storage of storages.values()) {
|
|
85
|
+
storage.initializeChannel()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
const GLOBAL_SINGLETON_KEY = '__talex_touch_storage_singletons__'
|
|
72
90
|
type StorageSingletonMap = Map<string, unknown>
|
|
73
91
|
|
|
@@ -113,14 +131,38 @@ export function createStorageProxy<T extends object>(key: string, factory: () =>
|
|
|
113
131
|
})
|
|
114
132
|
}
|
|
115
133
|
|
|
134
|
+
export function createStorageDataProxy<TData extends object>(
|
|
135
|
+
storage: { data: TData },
|
|
136
|
+
): TData {
|
|
137
|
+
return new Proxy({} as TData, {
|
|
138
|
+
get(_target, prop) {
|
|
139
|
+
const data = storage.data as Record<PropertyKey, unknown>
|
|
140
|
+
const value = data[prop as PropertyKey]
|
|
141
|
+
return typeof value === 'function' ? value.bind(data) : value
|
|
142
|
+
},
|
|
143
|
+
set(_target, prop, value) {
|
|
144
|
+
(storage.data as Record<PropertyKey, unknown>)[prop as PropertyKey] = value
|
|
145
|
+
return true
|
|
146
|
+
},
|
|
147
|
+
has(_target, prop) {
|
|
148
|
+
return prop in (storage.data as Record<PropertyKey, unknown>)
|
|
149
|
+
},
|
|
150
|
+
ownKeys() {
|
|
151
|
+
return Reflect.ownKeys(storage.data as Record<PropertyKey, unknown>)
|
|
152
|
+
},
|
|
153
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
154
|
+
return Object.getOwnPropertyDescriptor(
|
|
155
|
+
storage.data as Record<PropertyKey, unknown>,
|
|
156
|
+
prop,
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
116
162
|
/**
|
|
117
163
|
* Save result from main process
|
|
118
164
|
*/
|
|
119
|
-
export
|
|
120
|
-
success: boolean
|
|
121
|
-
version: number
|
|
122
|
-
conflict?: boolean
|
|
123
|
-
}
|
|
165
|
+
export type SaveResult = StorageSaveResult
|
|
124
166
|
|
|
125
167
|
/**
|
|
126
168
|
* A reactive storage utility with optional auto-save and update subscriptions.
|
|
@@ -138,6 +180,17 @@ export class TouchStorage<T extends object> {
|
|
|
138
180
|
#skipNextWatchTrigger = false
|
|
139
181
|
#currentVersion = 0
|
|
140
182
|
#isRemoteUpdate = false
|
|
183
|
+
#hydrated = false
|
|
184
|
+
readonly #hydratedPromise: Promise<void>
|
|
185
|
+
#resolveHydratedPromise: (() => void) | null = null
|
|
186
|
+
#pendingSave = false
|
|
187
|
+
#localDirty = false
|
|
188
|
+
#lastSyncedSnapshot: T | null = null
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Reactive saving state — true while a save is in flight.
|
|
192
|
+
*/
|
|
193
|
+
public readonly savingState = ref(false)
|
|
141
194
|
|
|
142
195
|
/**
|
|
143
196
|
* The reactive data exposed to users.
|
|
@@ -146,7 +199,8 @@ export class TouchStorage<T extends object> {
|
|
|
146
199
|
|
|
147
200
|
/**
|
|
148
201
|
* Creates a new reactive storage instance.
|
|
149
|
-
*
|
|
202
|
+
* NOTE: `initStorageChannel()` or `initStorageTransport()` can be called later;
|
|
203
|
+
* storage will sync once the channel/transport is ready.
|
|
150
204
|
*
|
|
151
205
|
* @param qName Globally unique name for the instance
|
|
152
206
|
* @param initData Initial data to populate the storage
|
|
@@ -156,20 +210,25 @@ export class TouchStorage<T extends object> {
|
|
|
156
210
|
*
|
|
157
211
|
* @example
|
|
158
212
|
* ```ts
|
|
159
|
-
* // First initialize the channel
|
|
213
|
+
* // First initialize the channel or transport
|
|
160
214
|
* initStorageChannel(touchChannel);
|
|
161
215
|
*
|
|
162
216
|
* // Then create storage instances
|
|
163
217
|
* const settings = new TouchStorage('settings', { darkMode: false });
|
|
164
218
|
* ```
|
|
165
219
|
*/
|
|
166
|
-
constructor(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
220
|
+
constructor(
|
|
221
|
+
qName: string,
|
|
222
|
+
initData: T,
|
|
223
|
+
onUpdate?: () => void,
|
|
224
|
+
options?: TouchStorageOptions,
|
|
225
|
+
) {
|
|
226
|
+
if (!channel && !transport) {
|
|
227
|
+
if (isElectronRenderer()) {
|
|
228
|
+
// Allow lazy initialization; channel/transport can be set later.
|
|
229
|
+
}
|
|
172
230
|
}
|
|
231
|
+
void options
|
|
173
232
|
|
|
174
233
|
if (storages.has(qName)) {
|
|
175
234
|
throw new Error(`Storage "${qName}" already exists`)
|
|
@@ -178,6 +237,10 @@ export class TouchStorage<T extends object> {
|
|
|
178
237
|
this.#qualifiedName = qName
|
|
179
238
|
this.originalData = initData
|
|
180
239
|
this.data = reactive({ ...initData }) as UnwrapNestedRefs<T>
|
|
240
|
+
this.#lastSyncedSnapshot = cloneValue(initData) as T
|
|
241
|
+
this.#hydratedPromise = new Promise((resolve) => {
|
|
242
|
+
this.#resolveHydratedPromise = resolve
|
|
243
|
+
})
|
|
181
244
|
|
|
182
245
|
if (onUpdate)
|
|
183
246
|
this._onUpdate.push(onUpdate)
|
|
@@ -185,8 +248,10 @@ export class TouchStorage<T extends object> {
|
|
|
185
248
|
// Register to storages map immediately
|
|
186
249
|
storages.set(qName, this)
|
|
187
250
|
|
|
188
|
-
|
|
189
|
-
|
|
251
|
+
if (channel || transport) {
|
|
252
|
+
// Initialize channel-dependent operations immediately when ready
|
|
253
|
+
this.#initializeChannel()
|
|
254
|
+
}
|
|
190
255
|
}
|
|
191
256
|
|
|
192
257
|
/**
|
|
@@ -200,53 +265,158 @@ export class TouchStorage<T extends object> {
|
|
|
200
265
|
|
|
201
266
|
this.#channelInitialized = true
|
|
202
267
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
268
|
+
void this.#loadFromRemoteWithVersion()
|
|
269
|
+
|
|
270
|
+
this.#registerUpdateListener()
|
|
271
|
+
|
|
272
|
+
// Start auto-save watcher AFTER initial data load
|
|
273
|
+
if (this.#autoSave && !this.#autoSaveStopHandle) {
|
|
274
|
+
this.#startAutoSaveWatcher()
|
|
208
275
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public initializeChannel(): void {
|
|
279
|
+
this.#initializeChannel()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#notifyHydrated(): void {
|
|
283
|
+
if (!this.#hydrated)
|
|
284
|
+
return
|
|
285
|
+
if (this.#resolveHydratedPromise) {
|
|
286
|
+
this.#resolveHydratedPromise()
|
|
287
|
+
this.#resolveHydratedPromise = null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async #getVersionedAsync(): Promise<StorageGetVersionedResponse | null> {
|
|
292
|
+
if (transport) {
|
|
293
|
+
return await transport.send(StorageEvents.app.getVersioned, { key: this.#qualifiedName })
|
|
294
|
+
}
|
|
295
|
+
if (channel) {
|
|
296
|
+
return await channel.send('storage:get-versioned', this.#qualifiedName) as StorageGetVersionedResponse | null
|
|
297
|
+
}
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async #getAsync(): Promise<Partial<T>> {
|
|
302
|
+
if (transport) {
|
|
303
|
+
const data = await transport.send(StorageEvents.app.get, { key: this.#qualifiedName })
|
|
304
|
+
return (data as Partial<T>) ?? {}
|
|
305
|
+
}
|
|
306
|
+
if (channel) {
|
|
307
|
+
const result = await channel.send('storage:get', this.#qualifiedName)
|
|
308
|
+
return result ? (result as Partial<T>) : {}
|
|
309
|
+
}
|
|
310
|
+
return {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async #saveRemote(request: StorageSaveRequest): Promise<SaveResult> {
|
|
314
|
+
if (transport) {
|
|
315
|
+
return await transport.send(StorageEvents.app.save, request)
|
|
316
|
+
}
|
|
317
|
+
if (channel) {
|
|
318
|
+
return await channel.send('storage:save', request) as SaveResult
|
|
319
|
+
}
|
|
320
|
+
return { success: false, version: 0 }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#registerUpdateListener(): void {
|
|
324
|
+
if (transport) {
|
|
325
|
+
transport.stream(StorageEvents.app.updated, undefined, {
|
|
326
|
+
onData: (payload: StorageUpdateNotification) => {
|
|
327
|
+
const { key, version } = payload
|
|
328
|
+
if (key !== this.#qualifiedName) {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (version === undefined || version > this.#currentVersion) {
|
|
333
|
+
void this.#loadFromRemoteWithVersion()
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
}).catch((error) => {
|
|
337
|
+
console.error('[TouchStorage] Failed to subscribe to storage updates:', error)
|
|
338
|
+
})
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!channel) {
|
|
343
|
+
return
|
|
214
344
|
}
|
|
215
345
|
|
|
216
346
|
// Register update listener - only triggered for OTHER windows' changes
|
|
217
347
|
// (source window is excluded by main process)
|
|
218
|
-
channel
|
|
348
|
+
channel.regChannel(StorageEvents.legacy.update.toEventName(), ({ data }) => {
|
|
219
349
|
const { name, version } = data as { name: string, version?: number }
|
|
220
350
|
|
|
221
351
|
if (name === this.#qualifiedName) {
|
|
222
352
|
// Only reload if remote version is newer
|
|
223
353
|
if (version === undefined || version > this.#currentVersion) {
|
|
224
|
-
this.#loadFromRemoteWithVersion()
|
|
354
|
+
void this.#loadFromRemoteWithVersion()
|
|
225
355
|
}
|
|
226
356
|
}
|
|
227
357
|
})
|
|
228
|
-
|
|
229
|
-
// Start auto-save watcher AFTER initial data load
|
|
230
|
-
if (this.#autoSave && !this.#autoSaveStopHandle) {
|
|
231
|
-
this.#startAutoSaveWatcher()
|
|
232
|
-
}
|
|
233
358
|
}
|
|
234
359
|
|
|
235
360
|
/**
|
|
236
361
|
* Load from remote and update version
|
|
237
362
|
* @private
|
|
238
363
|
*/
|
|
239
|
-
#loadFromRemoteWithVersion(): void {
|
|
240
|
-
if (!channel)
|
|
364
|
+
async #loadFromRemoteWithVersion(): Promise<void> {
|
|
365
|
+
if (!channel && !transport) {
|
|
241
366
|
return
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const versionedResult = await this.#getVersionedAsync()
|
|
370
|
+
if (versionedResult) {
|
|
371
|
+
const shouldApply = versionedResult.version > this.#currentVersion || !this.#hydrated
|
|
372
|
+
if (shouldApply) {
|
|
373
|
+
const patch = this.#localDirty
|
|
374
|
+
? buildPatch(this.#lastSyncedSnapshot ?? {}, toRaw(this.data))
|
|
375
|
+
: null
|
|
376
|
+
const patchHasChanges = Boolean(patch && (patch.set.length > 0 || patch.unset.length > 0))
|
|
377
|
+
const remoteData = (versionedResult.data ?? {}) as Partial<T>
|
|
378
|
+
console.debug(`[TouchStorage] HYDRATE("${this.#qualifiedName}") remote version=${versionedResult.version}, background.source=`, (remoteData as any)?.background?.source)
|
|
379
|
+
|
|
380
|
+
this.#currentVersion = versionedResult.version
|
|
381
|
+
this.#isRemoteUpdate = true
|
|
382
|
+
this.assignData(remoteData, true, true)
|
|
383
|
+
if (patchHasChanges && patch) {
|
|
384
|
+
this.#applyPatchSilently(patch)
|
|
385
|
+
this.#localDirty = true
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
this.#localDirty = false
|
|
389
|
+
}
|
|
390
|
+
this.#isRemoteUpdate = false
|
|
391
|
+
|
|
392
|
+
this.#lastSyncedSnapshot = cloneValue(toRaw(this.data) as T) as T
|
|
393
|
+
this.#hydrated = true
|
|
394
|
+
this.#notifyHydrated()
|
|
395
|
+
if (this.#pendingSave || patchHasChanges) {
|
|
396
|
+
this.#pendingSave = false
|
|
397
|
+
void this.saveToRemote({ force: true })
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return
|
|
401
|
+
}
|
|
242
402
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
this.#currentVersion = versionedResult.version
|
|
246
|
-
// Mark as remote update to skip auto-save
|
|
403
|
+
const result = await this.#getAsync()
|
|
404
|
+
this.#currentVersion = Math.max(this.#currentVersion, 1)
|
|
247
405
|
this.#isRemoteUpdate = true
|
|
248
|
-
this.assignData(
|
|
406
|
+
this.assignData(result as Partial<T>, true, true)
|
|
249
407
|
this.#isRemoteUpdate = false
|
|
408
|
+
this.#lastSyncedSnapshot = cloneValue(toRaw(this.data) as T) as T
|
|
409
|
+
this.#hydrated = true
|
|
410
|
+
this.#notifyHydrated()
|
|
411
|
+
if (this.#pendingSave) {
|
|
412
|
+
this.#pendingSave = false
|
|
413
|
+
void this.saveToRemote({ force: true })
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
console.error(`[TouchStorage] Failed to load "${this.#qualifiedName}" from remote:`, error)
|
|
418
|
+
this.#hydrated = true
|
|
419
|
+
this.#notifyHydrated()
|
|
250
420
|
}
|
|
251
421
|
}
|
|
252
422
|
|
|
@@ -262,6 +432,16 @@ export class TouchStorage<T extends object> {
|
|
|
262
432
|
return this.#qualifiedName
|
|
263
433
|
}
|
|
264
434
|
|
|
435
|
+
isHydrated(): boolean {
|
|
436
|
+
return this.#hydrated
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
whenHydrated(): Promise<void> {
|
|
440
|
+
if (this.#hydrated)
|
|
441
|
+
return Promise.resolve()
|
|
442
|
+
return this.#hydratedPromise
|
|
443
|
+
}
|
|
444
|
+
|
|
265
445
|
/**
|
|
266
446
|
* Checks whether auto-save is currently enabled.
|
|
267
447
|
*
|
|
@@ -285,35 +465,63 @@ export class TouchStorage<T extends object> {
|
|
|
285
465
|
* await store.saveToRemote();
|
|
286
466
|
* ```
|
|
287
467
|
*/
|
|
288
|
-
|
|
289
|
-
if (!channel) {
|
|
290
|
-
|
|
468
|
+
async #executeSave(options?: { force?: boolean }): Promise<void> {
|
|
469
|
+
if (!channel && !transport) {
|
|
470
|
+
console.warn(`[TouchStorage] #executeSave("${this.#qualifiedName}") SKIP: no channel/transport`)
|
|
471
|
+
if (isElectronRenderer()) {
|
|
472
|
+
throw new Error('TouchStorage: channel not initialized')
|
|
473
|
+
}
|
|
474
|
+
return
|
|
291
475
|
}
|
|
292
476
|
|
|
293
477
|
if (this.#assigning && !options?.force) {
|
|
478
|
+
console.warn(`[TouchStorage] #executeSave("${this.#qualifiedName}") SKIP: assigning (force=${options?.force})`)
|
|
294
479
|
return
|
|
295
480
|
}
|
|
296
481
|
|
|
297
482
|
// Skip save if this is a remote update (to avoid echo)
|
|
298
483
|
if (this.#isRemoteUpdate) {
|
|
484
|
+
console.warn(`[TouchStorage] #executeSave("${this.#qualifiedName}") SKIP: isRemoteUpdate`)
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
if (!this.#hydrated) {
|
|
488
|
+
console.warn(`[TouchStorage] #executeSave("${this.#qualifiedName}") SKIP: not hydrated, queueing`)
|
|
489
|
+
this.#pendingSave = true
|
|
490
|
+
this.#localDirty = true
|
|
299
491
|
return
|
|
300
492
|
}
|
|
301
493
|
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
494
|
+
const rawData = toRaw(this.data)
|
|
495
|
+
console.debug(`[TouchStorage] #executeSave("${this.#qualifiedName}") SAVING, background.source=`, (rawData as any)?.background?.source)
|
|
496
|
+
|
|
497
|
+
this.savingState.value = true
|
|
498
|
+
try {
|
|
499
|
+
const result = await this.#saveRemote({
|
|
500
|
+
key: this.#qualifiedName,
|
|
501
|
+
value: rawData,
|
|
502
|
+
clear: false,
|
|
503
|
+
version: this.#currentVersion,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
if (result.success) {
|
|
507
|
+
console.debug(`[TouchStorage] #executeSave("${this.#qualifiedName}") SUCCESS, version=${result.version}`)
|
|
508
|
+
this.#currentVersion = result.version
|
|
509
|
+
this.#lastSyncedSnapshot = cloneValue(toRaw(this.data) as T) as T
|
|
510
|
+
this.#localDirty = false
|
|
511
|
+
}
|
|
512
|
+
else if (result.conflict) {
|
|
513
|
+
// Conflict detected - reload from remote
|
|
514
|
+
console.warn(`[TouchStorage] Conflict detected for "${this.#qualifiedName}", reloading...`)
|
|
515
|
+
void this.#loadFromRemoteWithVersion()
|
|
516
|
+
}
|
|
311
517
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
console.warn(`[TouchStorage] Conflict detected for "${this.#qualifiedName}", reloading...`)
|
|
315
|
-
this.#loadFromRemoteWithVersion()
|
|
518
|
+
finally {
|
|
519
|
+
this.savingState.value = false
|
|
316
520
|
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
saveToRemote = useDebounceFn(async (options?: { force?: boolean }): Promise<void> => {
|
|
524
|
+
return this.#executeSave(options)
|
|
317
525
|
}, 300)
|
|
318
526
|
|
|
319
527
|
/**
|
|
@@ -355,11 +563,12 @@ export class TouchStorage<T extends object> {
|
|
|
355
563
|
|
|
356
564
|
this.#runAutoSavePipeline()
|
|
357
565
|
},
|
|
358
|
-
{ deep: true
|
|
566
|
+
{ deep: true },
|
|
359
567
|
)
|
|
360
568
|
}
|
|
361
569
|
|
|
362
570
|
#runAutoSavePipeline(options?: { force?: boolean }): void {
|
|
571
|
+
this.#localDirty = true
|
|
363
572
|
this._onUpdate.forEach((fn) => {
|
|
364
573
|
try {
|
|
365
574
|
fn()
|
|
@@ -372,6 +581,32 @@ export class TouchStorage<T extends object> {
|
|
|
372
581
|
this.saveToRemote(options)
|
|
373
582
|
}
|
|
374
583
|
|
|
584
|
+
#applyPatchSilently(patch: StoragePatch): void {
|
|
585
|
+
if (patch.set.length === 0 && patch.unset.length === 0) {
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (this.#autoSave) {
|
|
590
|
+
this.#assigning = true
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
applyPatch(this.data as Record<string, unknown>, patch)
|
|
594
|
+
|
|
595
|
+
if (this.#autoSave) {
|
|
596
|
+
this.#skipNextWatchTrigger = true
|
|
597
|
+
const resetAssigning = () => {
|
|
598
|
+
this.#assigning = false
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (typeof queueMicrotask === 'function') {
|
|
602
|
+
queueMicrotask(resetAssigning)
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
Promise.resolve().then(resetAssigning)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
375
610
|
/**
|
|
376
611
|
* Registers a callback that runs when data changes (only triggered in auto-save mode).
|
|
377
612
|
*
|
|
@@ -465,6 +700,25 @@ export class TouchStorage<T extends object> {
|
|
|
465
700
|
return this
|
|
466
701
|
}
|
|
467
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Applies remote snapshot data without triggering local auto-save echo.
|
|
705
|
+
*
|
|
706
|
+
* @param data Snapshot data pulled from remote sync source.
|
|
707
|
+
* @returns The current instance for chaining
|
|
708
|
+
*/
|
|
709
|
+
applyRemoteSnapshot(data: Partial<T>): this {
|
|
710
|
+
this.#isRemoteUpdate = true
|
|
711
|
+
try {
|
|
712
|
+
this.assignData(data, true, true)
|
|
713
|
+
this.#lastSyncedSnapshot = cloneValue(toRaw(this.data) as T) as T
|
|
714
|
+
this.#localDirty = false
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
this.#isRemoteUpdate = false
|
|
718
|
+
}
|
|
719
|
+
return this
|
|
720
|
+
}
|
|
721
|
+
|
|
468
722
|
/**
|
|
469
723
|
* Reloads data from remote storage and applies it.
|
|
470
724
|
*
|
|
@@ -476,13 +730,29 @@ export class TouchStorage<T extends object> {
|
|
|
476
730
|
* ```
|
|
477
731
|
*/
|
|
478
732
|
async reloadFromRemote(): Promise<this> {
|
|
479
|
-
if (!channel) {
|
|
733
|
+
if (!channel && !transport) {
|
|
480
734
|
throw new Error('TouchStorage: channel not initialized')
|
|
481
735
|
}
|
|
482
736
|
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
737
|
+
const versionedResult = await this.#getVersionedAsync()
|
|
738
|
+
if (versionedResult) {
|
|
739
|
+
this.#currentVersion = versionedResult.version
|
|
740
|
+
this.#isRemoteUpdate = true
|
|
741
|
+
this.assignData(versionedResult.data as Partial<T>, true, true)
|
|
742
|
+
this.#isRemoteUpdate = false
|
|
743
|
+
this.#lastSyncedSnapshot = cloneValue(toRaw(this.data) as T) as T
|
|
744
|
+
this.#hydrated = true
|
|
745
|
+
this.#notifyHydrated()
|
|
746
|
+
return this
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const parsed = await this.#getAsync()
|
|
750
|
+
this.#isRemoteUpdate = true
|
|
751
|
+
this.assignData(parsed as Partial<T>, true, true)
|
|
752
|
+
this.#isRemoteUpdate = false
|
|
753
|
+
this.#lastSyncedSnapshot = cloneValue(toRaw(this.data) as T) as T
|
|
754
|
+
this.#hydrated = true
|
|
755
|
+
this.#notifyHydrated()
|
|
486
756
|
|
|
487
757
|
return this
|
|
488
758
|
}
|
|
@@ -499,12 +769,12 @@ export class TouchStorage<T extends object> {
|
|
|
499
769
|
* ```
|
|
500
770
|
*/
|
|
501
771
|
loadFromRemote(): this {
|
|
502
|
-
if (!channel) {
|
|
772
|
+
if (!channel && !transport) {
|
|
503
773
|
// Channel not initialized yet, data will be loaded when channel is ready
|
|
504
774
|
return this
|
|
505
775
|
}
|
|
506
776
|
|
|
507
|
-
this.#loadFromRemoteWithVersion()
|
|
777
|
+
void this.#loadFromRemoteWithVersion()
|
|
508
778
|
return this
|
|
509
779
|
}
|
|
510
780
|
|
|
@@ -521,17 +791,13 @@ export class TouchStorage<T extends object> {
|
|
|
521
791
|
* This bypasses debouncing and saves immediately
|
|
522
792
|
*/
|
|
523
793
|
saveSync(): void {
|
|
524
|
-
if (!channel)
|
|
794
|
+
if (!channel && !transport)
|
|
525
795
|
return
|
|
526
796
|
if (this.#isRemoteUpdate)
|
|
527
797
|
return
|
|
528
798
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
content: JSON.stringify(this.data),
|
|
532
|
-
clear: false,
|
|
533
|
-
version: this.#currentVersion,
|
|
534
|
-
})
|
|
799
|
+
console.debug(`[TouchStorage] saveSync("${this.#qualifiedName}") called, background.source=`, (toRaw(this.data) as any)?.background?.source)
|
|
800
|
+
void this.#executeSave({ force: true })
|
|
535
801
|
}
|
|
536
802
|
|
|
537
803
|
/**
|
|
@@ -564,3 +830,163 @@ export class TouchStorage<T extends object> {
|
|
|
564
830
|
return this
|
|
565
831
|
}
|
|
566
832
|
}
|
|
833
|
+
|
|
834
|
+
interface StoragePatch {
|
|
835
|
+
set: Array<{ path: string[], value: unknown }>
|
|
836
|
+
unset: Array<string[]>
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function buildPatch(base: unknown, current: unknown): StoragePatch {
|
|
840
|
+
const patch: StoragePatch = { set: [], unset: [] }
|
|
841
|
+
walkDiff(base, current, [], patch)
|
|
842
|
+
return patch
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function walkDiff(base: unknown, current: unknown, path: string[], patch: StoragePatch): void {
|
|
846
|
+
if (isPlainObject(base) && isPlainObject(current)) {
|
|
847
|
+
const baseKeys = Object.keys(base)
|
|
848
|
+
const currentKeys = Object.keys(current)
|
|
849
|
+
const currentSet = new Set(currentKeys)
|
|
850
|
+
|
|
851
|
+
for (const key of baseKeys) {
|
|
852
|
+
if (!currentSet.has(key) || (current as Record<string, unknown>)[key] === undefined) {
|
|
853
|
+
patch.unset.push([...path, key])
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
for (const key of currentKeys) {
|
|
858
|
+
const currentValue = (current as Record<string, unknown>)[key]
|
|
859
|
+
if (currentValue === undefined) {
|
|
860
|
+
continue
|
|
861
|
+
}
|
|
862
|
+
if (!(key in (base as Record<string, unknown>))) {
|
|
863
|
+
patch.set.push({ path: [...path, key], value: cloneValue(currentValue) })
|
|
864
|
+
continue
|
|
865
|
+
}
|
|
866
|
+
walkDiff((base as Record<string, unknown>)[key], currentValue, [...path, key], patch)
|
|
867
|
+
}
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (isEqual(base, current)) {
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (path.length > 0) {
|
|
876
|
+
patch.set.push({ path: [...path], value: cloneValue(current) })
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function applyPatch(target: Record<string, unknown>, patch: StoragePatch): Record<string, unknown> {
|
|
881
|
+
for (const path of patch.unset) {
|
|
882
|
+
unsetByPath(target, path)
|
|
883
|
+
}
|
|
884
|
+
for (const entry of patch.set) {
|
|
885
|
+
setByPath(target, entry.path, cloneValue(entry.value))
|
|
886
|
+
}
|
|
887
|
+
return target
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function setByPath(target: Record<string, unknown>, path: string[], value: unknown): void {
|
|
891
|
+
if (path.length === 0) {
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
let cursor: Record<string, unknown> = target
|
|
895
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
896
|
+
const key = path[i]
|
|
897
|
+
if (!key)
|
|
898
|
+
continue
|
|
899
|
+
const next = cursor[key]
|
|
900
|
+
if (!isPlainObject(next)) {
|
|
901
|
+
cursor[key] = {}
|
|
902
|
+
}
|
|
903
|
+
cursor = cursor[key] as Record<string, unknown>
|
|
904
|
+
}
|
|
905
|
+
const lastKey = path[path.length - 1]
|
|
906
|
+
if (!lastKey)
|
|
907
|
+
return
|
|
908
|
+
cursor[lastKey] = value
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function unsetByPath(target: Record<string, unknown>, path: string[]): void {
|
|
912
|
+
if (path.length === 0) {
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
let cursor: Record<string, unknown> = target
|
|
916
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
917
|
+
const key = path[i]
|
|
918
|
+
if (!key)
|
|
919
|
+
return
|
|
920
|
+
const next = cursor[key]
|
|
921
|
+
if (!isPlainObject(next)) {
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
cursor = next as Record<string, unknown>
|
|
925
|
+
}
|
|
926
|
+
const lastKey = path[path.length - 1]
|
|
927
|
+
if (!lastKey)
|
|
928
|
+
return
|
|
929
|
+
delete cursor[lastKey]
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function isEqual(left: unknown, right: unknown): boolean {
|
|
933
|
+
if (left === right) {
|
|
934
|
+
return true
|
|
935
|
+
}
|
|
936
|
+
if (left instanceof Date && right instanceof Date) {
|
|
937
|
+
return left.getTime() === right.getTime()
|
|
938
|
+
}
|
|
939
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
940
|
+
if (left.length !== right.length) {
|
|
941
|
+
return false
|
|
942
|
+
}
|
|
943
|
+
for (let i = 0; i < left.length; i++) {
|
|
944
|
+
if (!isEqual(left[i], right[i])) {
|
|
945
|
+
return false
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return true
|
|
949
|
+
}
|
|
950
|
+
if (isPlainObject(left) && isPlainObject(right)) {
|
|
951
|
+
const leftKeys = Object.keys(left)
|
|
952
|
+
const rightKeys = Object.keys(right)
|
|
953
|
+
if (leftKeys.length !== rightKeys.length) {
|
|
954
|
+
return false
|
|
955
|
+
}
|
|
956
|
+
for (const key of leftKeys) {
|
|
957
|
+
if (!isEqual(left[key], right[key])) {
|
|
958
|
+
return false
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return true
|
|
962
|
+
}
|
|
963
|
+
return false
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
967
|
+
if (!value || typeof value !== 'object') {
|
|
968
|
+
return false
|
|
969
|
+
}
|
|
970
|
+
if (Array.isArray(value)) {
|
|
971
|
+
return false
|
|
972
|
+
}
|
|
973
|
+
const proto = Object.getPrototypeOf(value)
|
|
974
|
+
return proto === Object.prototype || proto === null
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function cloneValue<T>(value: T): T {
|
|
978
|
+
if (typeof structuredClone === 'function') {
|
|
979
|
+
try {
|
|
980
|
+
return structuredClone(value)
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// fall through
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
return value
|
|
991
|
+
}
|
|
992
|
+
}
|