@talex-touch/utils 1.0.42 → 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.
Files changed (233) hide show
  1. package/.eslintcache +1 -0
  2. package/__tests__/cloud-sync-sdk.test.ts +442 -0
  3. package/__tests__/icons/icons.test.ts +84 -0
  4. package/__tests__/plugin-sdk-lifecycle.test.ts +130 -0
  5. package/__tests__/power-sdk.test.ts +143 -0
  6. package/__tests__/preset-export-types.test.ts +108 -0
  7. package/__tests__/search/fuzzy-match.test.ts +137 -0
  8. package/__tests__/transport/port-policy.test.ts +44 -0
  9. package/__tests__/transport-domain-sdks.test.ts +152 -0
  10. package/__tests__/types/update.test.ts +67 -0
  11. package/account/account-sdk.ts +915 -0
  12. package/account/index.ts +2 -0
  13. package/account/types.ts +321 -0
  14. package/analytics/client.ts +136 -0
  15. package/analytics/index.ts +2 -0
  16. package/analytics/types.ts +156 -0
  17. package/animation/auto-resize.ts +322 -0
  18. package/animation/window-node.ts +26 -19
  19. package/auth/clerk-types.ts +12 -30
  20. package/auth/index.ts +0 -2
  21. package/auth/useAuthState.ts +6 -14
  22. package/base/index.ts +2 -0
  23. package/base/log-level.ts +105 -0
  24. package/channel/index.ts +170 -69
  25. package/cloud-sync/cloud-sync-sdk.ts +450 -0
  26. package/cloud-sync/index.ts +1 -0
  27. package/common/file-scan-utils.ts +17 -9
  28. package/common/index.ts +4 -0
  29. package/common/logger/index.ts +46 -0
  30. package/common/logger/logger-manager.ts +303 -0
  31. package/common/logger/module-logger.ts +270 -0
  32. package/common/logger/transport-logger.ts +234 -0
  33. package/common/logger/types.ts +93 -0
  34. package/common/search/gather.ts +48 -6
  35. package/common/search/index.ts +8 -0
  36. package/common/storage/constants.ts +13 -0
  37. package/common/storage/entity/app-settings.ts +245 -0
  38. package/common/storage/entity/index.ts +3 -0
  39. package/common/storage/entity/layout-atom-types.ts +147 -0
  40. package/common/storage/entity/openers.ts +1 -0
  41. package/common/storage/entity/preset-cloud-api.ts +132 -0
  42. package/common/storage/entity/preset-export-types.ts +256 -0
  43. package/common/storage/entity/shortcut-settings.ts +1 -0
  44. package/common/storage/shortcut-storage.ts +11 -0
  45. package/common/utils/clone-diagnostics.ts +105 -0
  46. package/common/utils/file.ts +16 -8
  47. package/common/utils/index.ts +6 -2
  48. package/common/utils/payload-preview.ts +173 -0
  49. package/common/utils/polling.ts +167 -13
  50. package/common/utils/safe-path.ts +103 -0
  51. package/common/utils/safe-shell.ts +115 -0
  52. package/common/utils/task-queue.ts +4 -1
  53. package/core-box/builder/tuff-builder.ts +0 -1
  54. package/core-box/index.ts +1 -1
  55. package/core-box/recommendation.ts +38 -1
  56. package/core-box/tuff/tuff-dsl.ts +32 -0
  57. package/electron/download-manager.ts +10 -7
  58. package/electron/env-tool.ts +42 -40
  59. package/electron/index.ts +0 -1
  60. package/env/index.ts +156 -0
  61. package/eslint.config.js +55 -0
  62. package/i18n/index.ts +62 -0
  63. package/i18n/locales/en.json +226 -0
  64. package/i18n/locales/zh.json +226 -0
  65. package/i18n/message-keys.ts +236 -0
  66. package/i18n/resolver.ts +181 -0
  67. package/icons/index.ts +257 -0
  68. package/icons/svg.ts +69 -0
  69. package/index.ts +9 -1
  70. package/intelligence/client.ts +72 -42
  71. package/market/constants.ts +9 -5
  72. package/market/index.ts +1 -1
  73. package/market/types.ts +19 -4
  74. package/package.json +15 -5
  75. package/permission/index.ts +143 -46
  76. package/permission/legacy.ts +26 -0
  77. package/permission/registry.ts +304 -0
  78. package/permission/types.ts +164 -0
  79. package/plugin/channel.ts +68 -39
  80. package/plugin/index.ts +80 -7
  81. package/plugin/install.ts +3 -0
  82. package/plugin/log/types.ts +22 -5
  83. package/plugin/node/logger-manager.ts +11 -3
  84. package/plugin/node/logger.ts +24 -17
  85. package/plugin/preload.ts +25 -2
  86. package/plugin/providers/index.ts +4 -4
  87. package/plugin/providers/market-client.ts +6 -3
  88. package/plugin/providers/npm-provider.ts +22 -7
  89. package/plugin/providers/tpex-provider.ts +22 -8
  90. package/plugin/sdk/box-items.ts +14 -0
  91. package/plugin/sdk/box-sdk.ts +64 -0
  92. package/plugin/sdk/channel.ts +119 -4
  93. package/plugin/sdk/clipboard.ts +26 -12
  94. package/plugin/sdk/cloud-sync.ts +113 -0
  95. package/plugin/sdk/common.ts +19 -11
  96. package/plugin/sdk/core-box.ts +6 -15
  97. package/plugin/sdk/division-box.ts +160 -65
  98. package/plugin/sdk/examples/storage-onDidChange-example.js +5 -2
  99. package/plugin/sdk/feature-sdk.ts +111 -76
  100. package/plugin/sdk/flow.ts +146 -45
  101. package/plugin/sdk/hooks/bridge.ts +13 -6
  102. package/plugin/sdk/hooks/life-cycle.ts +35 -16
  103. package/plugin/sdk/index.ts +14 -3
  104. package/plugin/sdk/intelligence.ts +87 -0
  105. package/plugin/sdk/meta/README.md +179 -0
  106. package/plugin/sdk/meta-sdk.ts +244 -0
  107. package/plugin/sdk/notification.ts +9 -0
  108. package/plugin/sdk/plugin-info.ts +64 -0
  109. package/plugin/sdk/power.ts +155 -0
  110. package/plugin/sdk/recommend.ts +21 -0
  111. package/plugin/sdk/service/index.ts +12 -8
  112. package/plugin/sdk/sqlite.ts +141 -0
  113. package/plugin/sdk/storage.ts +2 -6
  114. package/plugin/sdk/system.ts +2 -9
  115. package/plugin/sdk/temp-files.ts +41 -0
  116. package/plugin/sdk/touch-sdk.ts +18 -0
  117. package/plugin/sdk/types.ts +44 -4
  118. package/plugin/sdk/window/index.ts +12 -9
  119. package/plugin/sdk-version.ts +231 -0
  120. package/preload/renderer.ts +3 -2
  121. package/renderer/hooks/arg-mapper.ts +16 -2
  122. package/renderer/hooks/index.ts +13 -0
  123. package/renderer/hooks/initialize.ts +2 -1
  124. package/renderer/hooks/use-agent-market-sdk.ts +7 -0
  125. package/renderer/hooks/use-agent-market.ts +106 -0
  126. package/renderer/hooks/use-agents-sdk.ts +7 -0
  127. package/renderer/hooks/use-app-sdk.ts +7 -0
  128. package/renderer/hooks/use-channel.ts +33 -4
  129. package/renderer/hooks/use-download-sdk.ts +21 -0
  130. package/renderer/hooks/use-intelligence-sdk.ts +7 -0
  131. package/renderer/hooks/use-intelligence-stats.ts +290 -0
  132. package/renderer/hooks/use-intelligence.ts +55 -214
  133. package/renderer/hooks/use-market-sdk.ts +16 -0
  134. package/renderer/hooks/use-notification-sdk.ts +7 -0
  135. package/renderer/hooks/use-permission-sdk.ts +7 -0
  136. package/renderer/hooks/use-permission.ts +325 -0
  137. package/renderer/hooks/use-platform-sdk.ts +7 -0
  138. package/renderer/hooks/use-plugin-sdk.ts +16 -0
  139. package/renderer/hooks/use-settings-sdk.ts +7 -0
  140. package/renderer/hooks/use-update-sdk.ts +21 -0
  141. package/renderer/index.ts +1 -0
  142. package/renderer/ref.ts +19 -10
  143. package/renderer/shared/components/SharedPluginDetailContent.vue +84 -0
  144. package/renderer/shared/components/SharedPluginDetailHeader.vue +116 -0
  145. package/renderer/shared/components/SharedPluginDetailMetaList.vue +39 -0
  146. package/renderer/shared/components/SharedPluginDetailReadme.vue +45 -0
  147. package/renderer/shared/components/SharedPluginDetailVersions.vue +98 -0
  148. package/renderer/shared/components/index.ts +5 -0
  149. package/renderer/shared/components/shims-vue.d.ts +5 -0
  150. package/renderer/shared/index.ts +2 -0
  151. package/renderer/shared/plugin-detail.ts +62 -0
  152. package/renderer/storage/app-settings.ts +3 -1
  153. package/renderer/storage/base-storage.ts +508 -82
  154. package/renderer/storage/intelligence-storage.ts +31 -40
  155. package/renderer/storage/openers.ts +3 -1
  156. package/renderer/storage/storage-subscription.ts +126 -42
  157. package/renderer/touch-sdk/env.ts +10 -10
  158. package/renderer/touch-sdk/index.ts +114 -18
  159. package/renderer/touch-sdk/terminal.ts +24 -13
  160. package/search/feature-matcher.ts +279 -0
  161. package/search/fuzzy-match.ts +64 -34
  162. package/search/index.ts +10 -0
  163. package/search/levenshtein-utils.ts +17 -11
  164. package/transport/errors.ts +310 -0
  165. package/transport/event/builder.ts +378 -0
  166. package/transport/event/index.ts +7 -0
  167. package/transport/event/types.ts +292 -0
  168. package/transport/events/index.ts +2670 -0
  169. package/transport/events/meta-overlay.ts +79 -0
  170. package/transport/events/types/agents.ts +177 -0
  171. package/transport/events/types/app-index.ts +9 -0
  172. package/transport/events/types/app.ts +475 -0
  173. package/transport/events/types/box-item.ts +222 -0
  174. package/transport/events/types/clipboard.ts +80 -0
  175. package/transport/events/types/core-box.ts +534 -0
  176. package/transport/events/types/device-idle.ts +7 -0
  177. package/transport/events/types/division-box.ts +99 -0
  178. package/transport/events/types/download.ts +115 -0
  179. package/transport/events/types/file-index.ts +73 -0
  180. package/transport/events/types/flow.ts +149 -0
  181. package/transport/events/types/index.ts +70 -0
  182. package/transport/events/types/market.ts +39 -0
  183. package/transport/events/types/meta-overlay.ts +184 -0
  184. package/transport/events/types/notification.ts +140 -0
  185. package/transport/events/types/permission.ts +90 -0
  186. package/transport/events/types/platform.ts +8 -0
  187. package/transport/events/types/plugin.ts +620 -0
  188. package/transport/events/types/sentry.ts +20 -0
  189. package/transport/events/types/storage.ts +208 -0
  190. package/transport/events/types/transport.ts +60 -0
  191. package/transport/events/types/tray.ts +16 -0
  192. package/transport/events/types/update.ts +78 -0
  193. package/transport/index.ts +139 -0
  194. package/transport/main.ts +2 -0
  195. package/transport/sdk/constants.ts +29 -0
  196. package/transport/sdk/domains/agents-market.ts +47 -0
  197. package/transport/sdk/domains/agents.ts +62 -0
  198. package/transport/sdk/domains/app.ts +48 -0
  199. package/transport/sdk/domains/disposable.ts +35 -0
  200. package/transport/sdk/domains/download.ts +139 -0
  201. package/transport/sdk/domains/index.ts +13 -0
  202. package/transport/sdk/domains/intelligence.ts +616 -0
  203. package/transport/sdk/domains/market.ts +35 -0
  204. package/transport/sdk/domains/notification.ts +62 -0
  205. package/transport/sdk/domains/permission.ts +85 -0
  206. package/transport/sdk/domains/platform.ts +19 -0
  207. package/transport/sdk/domains/plugin.ts +144 -0
  208. package/transport/sdk/domains/settings.ts +92 -0
  209. package/transport/sdk/domains/update.ts +64 -0
  210. package/transport/sdk/index.ts +60 -0
  211. package/transport/sdk/main-transport.ts +710 -0
  212. package/transport/sdk/main.ts +9 -0
  213. package/transport/sdk/plugin-transport.ts +654 -0
  214. package/transport/sdk/port-policy.ts +38 -0
  215. package/transport/sdk/renderer-transport.ts +1165 -0
  216. package/transport/types.ts +605 -0
  217. package/types/agent.ts +399 -0
  218. package/types/cloud-sync.ts +157 -0
  219. package/types/division-box.ts +31 -31
  220. package/types/download.ts +1 -0
  221. package/types/flow.ts +63 -12
  222. package/types/icon.ts +2 -1
  223. package/types/index.ts +5 -0
  224. package/types/intelligence.ts +166 -173
  225. package/types/modules/base.ts +2 -0
  226. package/types/path-browserify.d.ts +5 -0
  227. package/types/platform.ts +12 -0
  228. package/types/startup-info.ts +32 -0
  229. package/types/touch-app-core.ts +8 -8
  230. package/types/update.ts +94 -1
  231. package/vitest.config.ts +25 -0
  232. package/auth/useClerkConfig.ts +0 -40
  233. 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
- reactive,
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
- * Synchronous send interface
25
- * @param event Event name
26
- * @param payload Event payload
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 interface SaveResult {
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
- * IMPORTANT: `initStorageChannel()` must be called before creating any TouchStorage instances.
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(qName: string, initData: T, onUpdate?: () => void) {
167
- if (!channel) {
168
- throw new Error(
169
- `TouchStorage: Cannot create storage "${qName}" before channel is initialized. `
170
- + 'Please call initStorageChannel() first.',
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
- // Initialize channel-dependent operations immediately
189
- this.#initializeChannel()
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
- // Try to get versioned data first, fallback to legacy
204
- const versionedResult = channel!.sendSync('storage:get-versioned', this.#qualifiedName) as { data: Partial<T>, version: number } | null
205
- if (versionedResult) {
206
- this.#currentVersion = versionedResult.version
207
- this.assignData(versionedResult.data, true, true)
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
- else {
210
- const result = channel!.sendSync('storage:get', this.#qualifiedName)
211
- const parsed = result ? (result as Partial<T>) : {}
212
- this.#currentVersion = 1
213
- this.assignData(parsed, true, true)
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!.regChannel('storage:update', ({ data }) => {
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
- const versionedResult = channel.sendSync('storage:get-versioned', this.#qualifiedName) as { data: Partial<T>, version: number } | null
244
- if (versionedResult && versionedResult.version > this.#currentVersion) {
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(versionedResult.data, true, true)
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
- saveToRemote = useDebounceFn(async (options?: { force?: boolean }): Promise<void> => {
289
- if (!channel) {
290
- throw new Error('TouchStorage: channel not initialized')
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 result = await channel.send('storage:save', {
303
- key: this.#qualifiedName,
304
- content: JSON.stringify(this.data),
305
- clear: false,
306
- version: this.#currentVersion,
307
- }) as SaveResult
308
-
309
- if (result.success) {
310
- this.#currentVersion = result.version
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
- else if (result.conflict) {
313
- // Conflict detected - reload from remote
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, immediate: 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 result = await channel.send('storage:reload', this.#qualifiedName)
484
- const parsed = result ? (result as Partial<T>) : {}
485
- this.assignData(parsed, true)
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
- channel.sendSync('storage:save-sync', {
530
- key: this.#qualifiedName,
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
+ }