electron-pinia-sync 1.0.0
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/CONTRIBUTING.md +405 -0
- package/LICENSE +21 -0
- package/README.md +458 -0
- package/dist/main/index.d.ts +153 -0
- package/dist/main/index.js +221 -0
- package/dist/preload/index.d.ts +2 -0
- package/dist/preload/index.js +60 -0
- package/dist/renderer/index.d.ts +123 -0
- package/dist/renderer/index.js +199 -0
- package/package.json +86 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { ipcMain, BrowserWindow } from 'electron';
|
|
2
|
+
import { createPinia } from 'pinia';
|
|
3
|
+
import Store from 'electron-store';
|
|
4
|
+
|
|
5
|
+
// src/main/index.ts
|
|
6
|
+
|
|
7
|
+
// src/types.ts
|
|
8
|
+
var IPC_CHANNELS = {
|
|
9
|
+
/** Renderer requests initial state from Main */
|
|
10
|
+
STATE_PULL: "pinia-sync:state-pull",
|
|
11
|
+
/** Renderer sends patch to Main */
|
|
12
|
+
STATE_PATCH: "pinia-sync:state-patch",
|
|
13
|
+
/** Main broadcasts state update to all Renderers */
|
|
14
|
+
STATE_UPDATED: "pinia-sync:state-updated"};
|
|
15
|
+
|
|
16
|
+
// src/debug.ts
|
|
17
|
+
function createDebugLogger(namespace, debugLevel = false, customLogger) {
|
|
18
|
+
const isEnabled = debugLevel !== false;
|
|
19
|
+
const isVerbose = debugLevel === "verbose" || debugLevel === true;
|
|
20
|
+
const prefix = `[${namespace}]`;
|
|
21
|
+
const noop = () => {
|
|
22
|
+
};
|
|
23
|
+
const logger = customLogger || console;
|
|
24
|
+
return {
|
|
25
|
+
log: logger.log?.bind(logger, prefix) || console.log.bind(console, prefix),
|
|
26
|
+
warn: logger.warn?.bind(logger, prefix) || console.warn.bind(console, prefix),
|
|
27
|
+
error: logger.error?.bind(logger, prefix) || console.error.bind(console, prefix),
|
|
28
|
+
debug: isEnabled ? logger.log?.bind(logger, prefix) || console.log.bind(console, prefix) : noop,
|
|
29
|
+
verbose: isVerbose ? logger.log?.bind(logger, `${prefix}[VERBOSE]`) || console.log.bind(console, `${prefix}[VERBOSE]`) : noop
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function formatStateForDebug(state, maxLength = 200) {
|
|
33
|
+
try {
|
|
34
|
+
const json = JSON.stringify(state);
|
|
35
|
+
if (json.length > maxLength) {
|
|
36
|
+
return json.substring(0, maxLength) + `... (${json.length} chars total)`;
|
|
37
|
+
}
|
|
38
|
+
return json;
|
|
39
|
+
} catch {
|
|
40
|
+
return "[Circular or non-serializable]";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function formatPatchForDebug(patch) {
|
|
44
|
+
try {
|
|
45
|
+
const keys = Object.keys(patch);
|
|
46
|
+
if (keys.length === 0) {
|
|
47
|
+
return "{}";
|
|
48
|
+
}
|
|
49
|
+
if (keys.length > 5) {
|
|
50
|
+
return `{ ${keys.slice(0, 5).join(", ")}, ... (${keys.length} keys) }`;
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(patch, null, 2);
|
|
53
|
+
} catch {
|
|
54
|
+
return "[Invalid patch]";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/utils/toRawState.ts
|
|
59
|
+
function toRawState(state) {
|
|
60
|
+
return JSON.parse(JSON.stringify(state));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/main/index.ts
|
|
64
|
+
var MainSync = class {
|
|
65
|
+
pinia;
|
|
66
|
+
electronStore;
|
|
67
|
+
storeMetadata = /* @__PURE__ */ new Map();
|
|
68
|
+
processingTransactions = /* @__PURE__ */ new Set();
|
|
69
|
+
MAX_TRANSACTION_HISTORY = 100;
|
|
70
|
+
debug;
|
|
71
|
+
constructor(options = {}) {
|
|
72
|
+
this.debug = createDebugLogger("electron-pinia-sync:main", options.debug ?? false, options.logger);
|
|
73
|
+
this.debug.debug("Initializing MainSync");
|
|
74
|
+
this.pinia = options.pinia ?? createPinia();
|
|
75
|
+
this.electronStore = new Store({
|
|
76
|
+
name: "pinia-sync-store",
|
|
77
|
+
...options.storeOptions
|
|
78
|
+
});
|
|
79
|
+
this.debug.verbose("electron-store initialized with options:", options.storeOptions);
|
|
80
|
+
this.setupIpcHandlers();
|
|
81
|
+
this.debug.debug("MainSync initialized successfully");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get the Pinia instance managed by this sync manager
|
|
85
|
+
*/
|
|
86
|
+
getPinia() {
|
|
87
|
+
return this.pinia;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Register a store with the sync manager
|
|
91
|
+
*/
|
|
92
|
+
registerStore(storeId, store, options = {}) {
|
|
93
|
+
this.debug.debug(`Registering store: ${storeId}`);
|
|
94
|
+
const persistConfig = this.normalizePersistOptions(options.persist);
|
|
95
|
+
this.storeMetadata.set(storeId, {
|
|
96
|
+
persist: persistConfig
|
|
97
|
+
});
|
|
98
|
+
if (persistConfig) {
|
|
99
|
+
const key = persistConfig.key ?? storeId;
|
|
100
|
+
const persistedState = this.electronStore.get(key);
|
|
101
|
+
if (persistedState) {
|
|
102
|
+
this.debug.verbose(`Loading persisted state for ${storeId}:`, formatStateForDebug(persistedState));
|
|
103
|
+
store.$patch(persistedState);
|
|
104
|
+
} else {
|
|
105
|
+
this.debug.verbose(`No persisted state found for ${storeId}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
store.$subscribe((_mutation, state) => {
|
|
109
|
+
this.debug.verbose(`Store ${storeId} changed:`, formatStateForDebug(state));
|
|
110
|
+
const serializedState = toRawState(state);
|
|
111
|
+
if (persistConfig) {
|
|
112
|
+
const key = persistConfig.key ?? storeId;
|
|
113
|
+
this.electronStore.set(key, serializedState);
|
|
114
|
+
this.debug.verbose(`Persisted state for ${storeId} to key: ${key}`);
|
|
115
|
+
}
|
|
116
|
+
this.broadcastStateUpdate(storeId, serializedState);
|
|
117
|
+
}, { detached: true });
|
|
118
|
+
this.debug.debug(`Store ${storeId} registered successfully (persist: ${!!persistConfig})`);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Normalize persist options to standard format
|
|
122
|
+
*/
|
|
123
|
+
normalizePersistOptions(persist) {
|
|
124
|
+
if (persist === true) {
|
|
125
|
+
return { enabled: true };
|
|
126
|
+
} else if (persist === false || persist === void 0) {
|
|
127
|
+
return false;
|
|
128
|
+
} else {
|
|
129
|
+
return persist;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Setup IPC handlers for communication with renderers
|
|
134
|
+
*/
|
|
135
|
+
setupIpcHandlers() {
|
|
136
|
+
this.debug.debug("Setting up IPC handlers");
|
|
137
|
+
ipcMain.handle(
|
|
138
|
+
IPC_CHANNELS.STATE_PULL,
|
|
139
|
+
async (_event, request) => {
|
|
140
|
+
this.debug.debug(`IPC handler called: STATE_PULL for store: ${request.storeId}`);
|
|
141
|
+
const store = this.pinia._s.get(request.storeId);
|
|
142
|
+
if (store) {
|
|
143
|
+
this.debug.verbose(`Sending state for ${request.storeId}:`, formatStateForDebug(store.$state));
|
|
144
|
+
} else {
|
|
145
|
+
this.debug.warn(`Store "${request.storeId}" not found in Main process`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
storeId: request.storeId,
|
|
149
|
+
state: store ? toRawState(store.$state) : null
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
this.debug.debug(`IPC handler registered: ${IPC_CHANNELS.STATE_PULL}`);
|
|
154
|
+
ipcMain.handle(
|
|
155
|
+
IPC_CHANNELS.STATE_PATCH,
|
|
156
|
+
async (_event, message) => {
|
|
157
|
+
this.debug.debug(`IPC handler called: STATE_PATCH for store: ${message.storeId}, transaction: ${message.transactionId}`);
|
|
158
|
+
this.debug.verbose(`Patch data:`, formatPatchForDebug(message.patch));
|
|
159
|
+
const store = this.pinia._s.get(message.storeId);
|
|
160
|
+
if (!store) {
|
|
161
|
+
this.debug.warn(`Store "${message.storeId}" not found in Main process`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.addTransaction(message.transactionId);
|
|
165
|
+
try {
|
|
166
|
+
store.$patch(message.patch);
|
|
167
|
+
this.debug.debug(`Successfully applied patch to store: ${message.storeId}`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
this.debug.error(`Failed to patch store "${message.storeId}":`, error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
this.debug.debug(`IPC handler registered: ${IPC_CHANNELS.STATE_PATCH}`);
|
|
174
|
+
this.debug.debug("IPC handlers setup complete");
|
|
175
|
+
}
|
|
176
|
+
addTransaction(id) {
|
|
177
|
+
this.processingTransactions.add(id);
|
|
178
|
+
if (this.processingTransactions.size > this.MAX_TRANSACTION_HISTORY) {
|
|
179
|
+
const first = this.processingTransactions.values().next().value;
|
|
180
|
+
if (first) this.processingTransactions.delete(first);
|
|
181
|
+
}
|
|
182
|
+
setTimeout(() => this.processingTransactions.delete(id), 5e3);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Broadcast state update to all renderer processes
|
|
186
|
+
*/
|
|
187
|
+
broadcastStateUpdate(storeId, state, transactionId) {
|
|
188
|
+
this.debug.verbose(`Broadcasting state update for store: ${storeId}`, transactionId ? `(transaction: ${transactionId})` : "");
|
|
189
|
+
const message = {
|
|
190
|
+
storeId,
|
|
191
|
+
state,
|
|
192
|
+
transactionId
|
|
193
|
+
};
|
|
194
|
+
const windows = BrowserWindow.getAllWindows();
|
|
195
|
+
this.debug.verbose(`Broadcasting to ${windows.length} window(s)`);
|
|
196
|
+
windows.forEach((window, index) => {
|
|
197
|
+
if (!window.isDestroyed()) {
|
|
198
|
+
this.debug.verbose(`Sending ${IPC_CHANNELS.STATE_UPDATED} to window ${index + 1}`);
|
|
199
|
+
window.webContents.send(IPC_CHANNELS.STATE_UPDATED, message);
|
|
200
|
+
} else {
|
|
201
|
+
this.debug.verbose(`Skipping destroyed window ${index + 1}`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
this.debug.debug(`Broadcast complete for store: ${storeId}`);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Cleanup IPC handlers
|
|
208
|
+
*/
|
|
209
|
+
destroy() {
|
|
210
|
+
this.debug.debug("Destroying MainSync, cleaning up IPC handlers");
|
|
211
|
+
ipcMain.removeHandler(IPC_CHANNELS.STATE_PULL);
|
|
212
|
+
ipcMain.removeHandler(IPC_CHANNELS.STATE_PATCH);
|
|
213
|
+
this.debug.debug("MainSync destroyed");
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
function createMainSync(options) {
|
|
217
|
+
return new MainSync(options);
|
|
218
|
+
}
|
|
219
|
+
console.log("main sync plugin loaded");
|
|
220
|
+
|
|
221
|
+
export { MainSync, createDebugLogger, createMainSync, formatPatchForDebug, formatStateForDebug };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { contextBridge, ipcRenderer } from 'electron';
|
|
2
|
+
|
|
3
|
+
// src/preload/index.ts
|
|
4
|
+
|
|
5
|
+
// src/types.ts
|
|
6
|
+
var IPC_CHANNELS = {
|
|
7
|
+
/** Renderer requests initial state from Main */
|
|
8
|
+
STATE_PULL: "pinia-sync:state-pull",
|
|
9
|
+
/** Renderer sends patch to Main */
|
|
10
|
+
STATE_PATCH: "pinia-sync:state-patch",
|
|
11
|
+
/** Main broadcasts state update to all Renderers */
|
|
12
|
+
STATE_UPDATED: "pinia-sync:state-updated"};
|
|
13
|
+
|
|
14
|
+
// src/preload/index.ts
|
|
15
|
+
var piniaSyncAPI = {
|
|
16
|
+
/**
|
|
17
|
+
* Pull initial state from Main process
|
|
18
|
+
*/
|
|
19
|
+
async pullState(storeId) {
|
|
20
|
+
console.log(`[preload] IPC invoke: ${IPC_CHANNELS.STATE_PULL} for store "${storeId}"`);
|
|
21
|
+
const request = { storeId };
|
|
22
|
+
const response = await ipcRenderer.invoke(
|
|
23
|
+
IPC_CHANNELS.STATE_PULL,
|
|
24
|
+
request
|
|
25
|
+
);
|
|
26
|
+
console.log(`[preload] IPC response: STATE_PULL for "${storeId}" - state:`, response.state ? "received" : "null");
|
|
27
|
+
return response.state;
|
|
28
|
+
},
|
|
29
|
+
/**
|
|
30
|
+
* Send state patch to Main process
|
|
31
|
+
*/
|
|
32
|
+
async patchState(storeId, patch, transactionId) {
|
|
33
|
+
console.log(`[preload] IPC invoke: ${IPC_CHANNELS.STATE_PATCH} for store "${storeId}", transaction: ${transactionId}`);
|
|
34
|
+
const message = {
|
|
35
|
+
storeId,
|
|
36
|
+
patch,
|
|
37
|
+
transactionId
|
|
38
|
+
};
|
|
39
|
+
await ipcRenderer.invoke(IPC_CHANNELS.STATE_PATCH, message);
|
|
40
|
+
console.log(`[preload] IPC response: STATE_PATCH for "${storeId}" completed`);
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to state updates from Main process
|
|
44
|
+
*/
|
|
45
|
+
onStateUpdate(callback) {
|
|
46
|
+
console.log(`[preload] IPC listener registered: ${IPC_CHANNELS.STATE_UPDATED}`);
|
|
47
|
+
const listener = (_event, message) => {
|
|
48
|
+
console.log(`[preload] IPC event received: ${IPC_CHANNELS.STATE_UPDATED} for store "${message.storeId}", transaction: ${message.transactionId || "none"}`);
|
|
49
|
+
callback(message);
|
|
50
|
+
};
|
|
51
|
+
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATED, listener);
|
|
52
|
+
return () => {
|
|
53
|
+
console.log(`[preload] IPC listener removed: ${IPC_CHANNELS.STATE_UPDATED}`);
|
|
54
|
+
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATED, listener);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
contextBridge.exposeInMainWorld("piniaSync", piniaSyncAPI);
|
|
59
|
+
console.log("[preload] piniaSync API exposed to window");
|
|
60
|
+
console.log("[preload] electron-pinia-sync preload script initialized");
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { StateTree, PiniaPluginContext } from 'pinia';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common types shared across Main, Renderer, and Preload processes
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Debug level configuration
|
|
9
|
+
*/
|
|
10
|
+
type DebugLevel$1 = boolean | 'verbose' | 'minimal';
|
|
11
|
+
/**
|
|
12
|
+
* Message sent from Main to Renderer when state is updated
|
|
13
|
+
*/
|
|
14
|
+
interface StateUpdateMessage {
|
|
15
|
+
/** Store ID */
|
|
16
|
+
storeId: string;
|
|
17
|
+
/** New state */
|
|
18
|
+
state: StateTree;
|
|
19
|
+
/** Transaction ID that caused this update (if any) */
|
|
20
|
+
transactionId?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Options for configuring store persistence
|
|
24
|
+
*/
|
|
25
|
+
interface PersistOptions {
|
|
26
|
+
/** Whether to persist this store to disk */
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
/** Optional custom key for electron-store (defaults to storeId) */
|
|
29
|
+
key?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extended Pinia store options with persistence support
|
|
33
|
+
*/
|
|
34
|
+
interface SyncStoreOptions {
|
|
35
|
+
/** Persistence configuration */
|
|
36
|
+
persist?: boolean | PersistOptions;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* API exposed to Renderer via contextBridge
|
|
40
|
+
*/
|
|
41
|
+
interface PiniaSyncAPI {
|
|
42
|
+
/**
|
|
43
|
+
* Pull initial state from Main process
|
|
44
|
+
*/
|
|
45
|
+
pullState: (storeId: string) => Promise<StateTree | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Send state patch to Main process
|
|
48
|
+
*/
|
|
49
|
+
patchState: (storeId: string, patch: Partial<StateTree>, transactionId: string) => Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Subscribe to state updates from Main process
|
|
52
|
+
*/
|
|
53
|
+
onStateUpdate: (callback: (message: StateUpdateMessage) => void) => () => void;
|
|
54
|
+
}
|
|
55
|
+
declare global {
|
|
56
|
+
interface Window {
|
|
57
|
+
piniaSync?: PiniaSyncAPI;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Debug utilities for electron-pinia-sync
|
|
63
|
+
*
|
|
64
|
+
* Enable debug logging programmatically:
|
|
65
|
+
* - { debug: true } - enable debug logging
|
|
66
|
+
* - { debug: 'verbose' } - enable verbose logging with state diffs
|
|
67
|
+
* - { debug: 'minimal' } - only log errors and warnings
|
|
68
|
+
*/
|
|
69
|
+
type DebugLevel = boolean | 'verbose' | 'minimal';
|
|
70
|
+
interface DebugLogger {
|
|
71
|
+
log: (message: string, ...args: unknown[]) => void;
|
|
72
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
73
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
74
|
+
debug: (message: string, ...args: unknown[]) => void;
|
|
75
|
+
verbose: (message: string, ...args: unknown[]) => void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Create a debug logger for a specific namespace
|
|
79
|
+
*/
|
|
80
|
+
declare function createDebugLogger(namespace: string, debugLevel?: DebugLevel, customLogger?: Partial<DebugLogger>): DebugLogger;
|
|
81
|
+
/**
|
|
82
|
+
* Format state for debug output (truncate large objects)
|
|
83
|
+
*/
|
|
84
|
+
declare function formatStateForDebug(state: unknown, maxLength?: number): string;
|
|
85
|
+
/**
|
|
86
|
+
* Format patch for debug output
|
|
87
|
+
*/
|
|
88
|
+
declare function formatPatchForDebug(patch: unknown): string;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Options for the renderer sync plugin
|
|
92
|
+
*/
|
|
93
|
+
interface RendererSyncOptions {
|
|
94
|
+
/**
|
|
95
|
+
* Enable debug logging
|
|
96
|
+
* - true: enable debug logging
|
|
97
|
+
* - 'verbose': enable verbose logging with state diffs
|
|
98
|
+
* - 'minimal': only log errors and warnings
|
|
99
|
+
* - false: disable debug logging (default)
|
|
100
|
+
*/
|
|
101
|
+
debug?: DebugLevel$1;
|
|
102
|
+
/**
|
|
103
|
+
* Custom logger (default: console)
|
|
104
|
+
*/
|
|
105
|
+
logger?: Partial<DebugLogger>;
|
|
106
|
+
/**
|
|
107
|
+
* Custom API implementation (for testing)
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
customApi?: PiniaSyncAPI;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Renderer process Pinia plugin
|
|
115
|
+
* Synchronizes local store changes with Main process and receives updates
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create the Pinia plugin for renderer process synchronization
|
|
120
|
+
*/
|
|
121
|
+
declare function createRendererSync(options?: RendererSyncOptions): (context: PiniaPluginContext) => void;
|
|
122
|
+
|
|
123
|
+
export { type DebugLevel, type DebugLogger, type SyncStoreOptions, createDebugLogger, createRendererSync, formatPatchForDebug, formatStateForDebug };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import diff from 'microdiff';
|
|
2
|
+
|
|
3
|
+
// src/renderer/index.ts
|
|
4
|
+
|
|
5
|
+
// src/debug.ts
|
|
6
|
+
function createDebugLogger(namespace, debugLevel = false, customLogger) {
|
|
7
|
+
const isEnabled = debugLevel !== false;
|
|
8
|
+
const isVerbose = debugLevel === "verbose" || debugLevel === true;
|
|
9
|
+
const prefix = `[${namespace}]`;
|
|
10
|
+
const noop = () => {
|
|
11
|
+
};
|
|
12
|
+
const logger = customLogger || console;
|
|
13
|
+
return {
|
|
14
|
+
log: logger.log?.bind(logger, prefix) || console.log.bind(console, prefix),
|
|
15
|
+
warn: logger.warn?.bind(logger, prefix) || console.warn.bind(console, prefix),
|
|
16
|
+
error: logger.error?.bind(logger, prefix) || console.error.bind(console, prefix),
|
|
17
|
+
debug: isEnabled ? logger.log?.bind(logger, prefix) || console.log.bind(console, prefix) : noop,
|
|
18
|
+
verbose: isVerbose ? logger.log?.bind(logger, `${prefix}[VERBOSE]`) || console.log.bind(console, `${prefix}[VERBOSE]`) : noop
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function formatStateForDebug(state, maxLength = 200) {
|
|
22
|
+
try {
|
|
23
|
+
const json = JSON.stringify(state);
|
|
24
|
+
if (json.length > maxLength) {
|
|
25
|
+
return json.substring(0, maxLength) + `... (${json.length} chars total)`;
|
|
26
|
+
}
|
|
27
|
+
return json;
|
|
28
|
+
} catch {
|
|
29
|
+
return "[Circular or non-serializable]";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function formatPatchForDebug(patch) {
|
|
33
|
+
try {
|
|
34
|
+
const keys = Object.keys(patch);
|
|
35
|
+
if (keys.length === 0) {
|
|
36
|
+
return "{}";
|
|
37
|
+
}
|
|
38
|
+
if (keys.length > 5) {
|
|
39
|
+
return `{ ${keys.slice(0, 5).join(", ")}, ... (${keys.length} keys) }`;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(patch, null, 2);
|
|
42
|
+
} catch {
|
|
43
|
+
return "[Invalid patch]";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/utils/toRawState.ts
|
|
48
|
+
function toRawState(state) {
|
|
49
|
+
return JSON.parse(JSON.stringify(state));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/renderer/index.ts
|
|
53
|
+
function generateTransactionId() {
|
|
54
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
55
|
+
}
|
|
56
|
+
function calculatePatch(oldState, newState, mutation) {
|
|
57
|
+
const differences = diff(oldState, newState);
|
|
58
|
+
if (differences.length === 0) {
|
|
59
|
+
return extractPatchFromMutation(mutation, newState);
|
|
60
|
+
}
|
|
61
|
+
const patch = {};
|
|
62
|
+
for (const change of differences) {
|
|
63
|
+
if (change.path.length === 0) {
|
|
64
|
+
return newState;
|
|
65
|
+
}
|
|
66
|
+
const topLevelKey = change.path[0];
|
|
67
|
+
if (typeof topLevelKey === "string" || typeof topLevelKey === "number") {
|
|
68
|
+
patch[topLevelKey] = newState[topLevelKey];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Object.keys(patch).length > 0 ? patch : extractPatchFromMutation(mutation, newState);
|
|
72
|
+
}
|
|
73
|
+
function extractPatchFromMutation(mutation, state) {
|
|
74
|
+
if (mutation.type === "patch object") {
|
|
75
|
+
return mutation.payload;
|
|
76
|
+
} else if (mutation.type === "patch function") {
|
|
77
|
+
return state;
|
|
78
|
+
} else if (mutation.type === "direct") {
|
|
79
|
+
const patch = {};
|
|
80
|
+
const events = mutation.events;
|
|
81
|
+
if (events && Array.isArray(events) && events.length > 0) {
|
|
82
|
+
const firstEvent = events[0];
|
|
83
|
+
const key = firstEvent?.key;
|
|
84
|
+
if (typeof key === "string" && key in state) {
|
|
85
|
+
patch[key] = state[key];
|
|
86
|
+
return patch;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return state;
|
|
90
|
+
} else {
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function createRendererSync(options = {}) {
|
|
95
|
+
const debug = createDebugLogger("electron-pinia-sync:renderer", options.debug ?? false, options.logger);
|
|
96
|
+
debug.debug("Initializing RendererSync");
|
|
97
|
+
let api = options.customApi;
|
|
98
|
+
if (!api) {
|
|
99
|
+
if (typeof window !== "undefined" && window.piniaSync) {
|
|
100
|
+
api = window.piniaSync;
|
|
101
|
+
} else if (typeof globalThis !== "undefined" && globalThis.window?.piniaSync) {
|
|
102
|
+
api = globalThis.window.piniaSync;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!api) {
|
|
106
|
+
debug.error("window.piniaSync is not available. Make sure the preload script is loaded correctly.");
|
|
107
|
+
throw new Error("Pinia sync API not available");
|
|
108
|
+
}
|
|
109
|
+
debug.debug("Pinia sync API found");
|
|
110
|
+
const processingTransactions = /* @__PURE__ */ new Set();
|
|
111
|
+
return function rendererSyncPlugin(context) {
|
|
112
|
+
const { store } = context;
|
|
113
|
+
debug.debug(`Initializing sync for store: ${store.$id}`);
|
|
114
|
+
let isApplyingRemoteUpdate = false;
|
|
115
|
+
let previousState = toRawState(store.$state);
|
|
116
|
+
const initializeState = async () => {
|
|
117
|
+
debug.debug(`Pulling initial state for store: ${store.$id}`);
|
|
118
|
+
try {
|
|
119
|
+
const state = await api.pullState(store.$id);
|
|
120
|
+
if (state !== null) {
|
|
121
|
+
debug.verbose(`Received initial state for ${store.$id}:`, formatStateForDebug(state));
|
|
122
|
+
isApplyingRemoteUpdate = true;
|
|
123
|
+
store.$patch(state);
|
|
124
|
+
previousState = toRawState(store.$state);
|
|
125
|
+
isApplyingRemoteUpdate = false;
|
|
126
|
+
debug.debug(`Successfully initialized state for store: ${store.$id}`);
|
|
127
|
+
} else {
|
|
128
|
+
debug.debug(`No initial state available for store: ${store.$id}`);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
debug.error(`Failed to pull initial state for store "${store.$id}":`, error);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const subscribeToLocalChanges = () => {
|
|
135
|
+
debug.debug(`Subscribing to local changes for store: ${store.$id}`);
|
|
136
|
+
store.$subscribe((mutation, state) => {
|
|
137
|
+
if (isApplyingRemoteUpdate) {
|
|
138
|
+
debug.verbose(`Skipping sync for ${store.$id} (applying remote update)`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
debug.verbose(`Local state changed for ${store.$id}, mutation type: ${mutation.type}`);
|
|
142
|
+
const patch = calculatePatch(previousState, state, mutation);
|
|
143
|
+
previousState = toRawState(state);
|
|
144
|
+
if (Object.keys(patch).length === 0) {
|
|
145
|
+
debug.verbose(`No changes detected for ${store.$id}, skipping sync`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
debug.verbose(`Calculated patch for ${store.$id}:`, formatPatchForDebug(patch));
|
|
149
|
+
const transactionId = generateTransactionId();
|
|
150
|
+
debug.debug(`Syncing patch to Main (transaction: ${transactionId})`);
|
|
151
|
+
processingTransactions.add(transactionId);
|
|
152
|
+
const rawPatch = toRawState(patch);
|
|
153
|
+
api.patchState(store.$id, rawPatch, transactionId).catch((error) => {
|
|
154
|
+
debug.error(`Failed to sync state for store "${store.$id}":`, error);
|
|
155
|
+
}).finally(() => {
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
processingTransactions.delete(transactionId);
|
|
158
|
+
}, 100);
|
|
159
|
+
});
|
|
160
|
+
}, { detached: true });
|
|
161
|
+
};
|
|
162
|
+
const subscribeToRemoteUpdates = () => {
|
|
163
|
+
debug.debug(`Subscribing to remote updates for store: ${store.$id}`);
|
|
164
|
+
const unsubscribe = api.onStateUpdate((message) => {
|
|
165
|
+
if (message.storeId !== store.$id) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
debug.verbose(`Received remote update for ${store.$id}`, message.transactionId ? `(transaction: ${message.transactionId})` : "");
|
|
169
|
+
if (message.transactionId && processingTransactions.has(message.transactionId)) {
|
|
170
|
+
debug.verbose(`Skipping echo update for ${store.$id} (transaction: ${message.transactionId})`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
debug.verbose(`Applying remote state to ${store.$id}:`, formatStateForDebug(message.state));
|
|
174
|
+
isApplyingRemoteUpdate = true;
|
|
175
|
+
store.$patch(message.state);
|
|
176
|
+
previousState = toRawState(store.$state);
|
|
177
|
+
isApplyingRemoteUpdate = false;
|
|
178
|
+
debug.debug(`Successfully applied remote update to store: ${store.$id}`);
|
|
179
|
+
});
|
|
180
|
+
store._piniaSync_unsubscribe = unsubscribe;
|
|
181
|
+
};
|
|
182
|
+
debug.debug(`Initializing store synchronization for: ${store.$id}`);
|
|
183
|
+
initializeState();
|
|
184
|
+
subscribeToLocalChanges();
|
|
185
|
+
subscribeToRemoteUpdates();
|
|
186
|
+
debug.debug(`Store ${store.$id} sync setup complete`);
|
|
187
|
+
const originalDispose = store.$dispose.bind(store);
|
|
188
|
+
store.$dispose = () => {
|
|
189
|
+
debug.debug(`Disposing store: ${store.$id}`);
|
|
190
|
+
const unsubscribe = store._piniaSync_unsubscribe;
|
|
191
|
+
if (unsubscribe) {
|
|
192
|
+
unsubscribe();
|
|
193
|
+
}
|
|
194
|
+
originalDispose();
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export { createDebugLogger, createRendererSync, formatPatchForDebug, formatStateForDebug };
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "electron-pinia-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A comprehensive Pinia plugin for Electron that synchronizes store states across multiple windows via IPC and provides seamless persistence through electron-store.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "simpli.fyi",
|
|
7
|
+
"website": "https://simpli.fyi",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"electron",
|
|
10
|
+
"pinia",
|
|
11
|
+
"vue",
|
|
12
|
+
"state-management",
|
|
13
|
+
"synchronization",
|
|
14
|
+
"ipc",
|
|
15
|
+
"persistence"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/simpli-fyi/electron-pinia-sync.git"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"packageManager": "npm@10.9.0",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.0.0"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
"./main": {
|
|
28
|
+
"types": "./dist/main/index.d.ts",
|
|
29
|
+
"import": "./dist/main/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./preload": {
|
|
32
|
+
"types": "./dist/preload/index.d.ts",
|
|
33
|
+
"import": "./dist/preload/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./renderer": {
|
|
36
|
+
"types": "./dist/renderer/index.d.ts",
|
|
37
|
+
"import": "./dist/renderer/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"CONTRIBUTING.md"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "npm run clean && npm run build:main && npm run build:renderer && npm run build:preload",
|
|
48
|
+
"build:main": "tsup src/main/index.ts --format esm --dts --outDir dist/main",
|
|
49
|
+
"build:preload": "tsup src/preload/index.ts --format esm --dts --outDir dist/preload",
|
|
50
|
+
"build:renderer": "tsup src/renderer/index.ts --format esm --dts --outDir dist/renderer",
|
|
51
|
+
"clean": "rm -rf dist",
|
|
52
|
+
"dev": "npm run build --watch",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"test:coverage": "vitest run --coverage",
|
|
56
|
+
"test:e2e": "playwright test",
|
|
57
|
+
"lint": "eslint src",
|
|
58
|
+
"lint:fix": "eslint src --fix",
|
|
59
|
+
"typecheck": "tsc --noEmit",
|
|
60
|
+
"prepublishOnly": "npm run build && npm run test && npm run typecheck"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"electron": ">=40.0.0",
|
|
64
|
+
"pinia": ">=3.0.0",
|
|
65
|
+
"vue": ">=3.5.0"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"electron-store": "^11.0.0",
|
|
69
|
+
"microdiff": "^1.5.0"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@playwright/test": "^1.58.0",
|
|
73
|
+
"@types/node": "^25.1.0",
|
|
74
|
+
"@vitest/coverage-v8": "^4.0.0",
|
|
75
|
+
"electron": "^40.1.0",
|
|
76
|
+
"eslint": "^9.39.0",
|
|
77
|
+
"pinia": "^3.0.0",
|
|
78
|
+
"playwright": "^1.58.0",
|
|
79
|
+
"tsup": "^8.5.0",
|
|
80
|
+
"tsx": "^4.21.0",
|
|
81
|
+
"typescript": "^5.7.0",
|
|
82
|
+
"typescript-eslint": "^8.32.0",
|
|
83
|
+
"vitest": "^4.0.0",
|
|
84
|
+
"vue": "^3.5.0"
|
|
85
|
+
}
|
|
86
|
+
}
|