feat(electron): shared storage (#7492)

This commit is contained in:
EYHN
2024-07-15 03:21:08 +00:00
parent 0f1409756e
commit dca88e24fe
22 changed files with 411 additions and 15 deletions

View File

@@ -1,6 +1,9 @@
import type { NamespaceHandlers } from '../type';
import { persistentConfig } from './persist';
/**
* @deprecated use shared storage
*/
export const configStorageHandlers = {
get: async () => persistentConfig.get(),
set: async (_, v) => persistentConfig.set(v),

View File

@@ -10,6 +10,9 @@ import { app } from 'electron';
const FILENAME = 'config.json';
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
/**
* @deprecated use shared storage
*/
export const persistentConfig = new AppConfigStorage({
config: defaultAppConfig,
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),

View File

@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron';
import { applicationMenuEvents } from './application-menu';
import { logger } from './logger';
import { sharedStorageEvents } from './shared-storage';
import { uiEvents } from './ui/events';
import { updaterEvents } from './updater/event';
@@ -9,6 +10,7 @@ export const allEvents = {
applicationMenu: applicationMenuEvents,
updater: updaterEvents,
ui: uiEvents,
sharedStorage: sharedStorageEvents,
};
function getActiveWindows() {

View File

@@ -5,6 +5,7 @@ import { configStorageHandlers } from './config-storage';
import { exportHandlers } from './export';
import { findInPageHandlers } from './find-in-page';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { sharedStorageHandlers } from './shared-storage';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
@@ -26,6 +27,7 @@ export const allHandlers = {
updater: updaterHandlers,
configStorage: configStorageHandlers,
findInPage: findInPageHandlers,
sharedStorage: sharedStorageHandlers,
};
export const registerHandlers = () => {
@@ -34,7 +36,10 @@ export const registerHandlers = () => {
for (const [namespace, namespaceHandlers] of Object.entries(allHandlers)) {
for (const [key, handler] of Object.entries(namespaceHandlers)) {
const chan = `${namespace}:${key}`;
ipcMain.handle(chan, async (e, ...args) => {
const wrapper = async (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => {
const start = performance.now();
try {
const result = await handler(e, ...args);
@@ -52,6 +57,18 @@ export const registerHandlers = () => {
} catch (error) {
logger.error('[ipc]', chan, error);
}
};
// for ipcRenderer.invoke
ipcMain.handle(chan, wrapper);
// for ipcRenderer.sendSync
ipcMain.on(chan, (e, ...args) => {
wrapper(e, ...args)
.then(ret => {
e.returnValue = ret;
})
.catch(() => {
// never throw
});
});
}
}

View File

@@ -0,0 +1,25 @@
import type { MainEventRegister } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
export const sharedStorageEvents = {
onGlobalStateChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalStateStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
},
onGlobalCacheChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalCacheStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,29 @@
import type { NamespaceHandlers } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
export const sharedStorageHandlers = {
getAllGlobalState: async () => {
return globalStateStorage.all();
},
getAllGlobalCache: async () => {
return globalCacheStorage.all();
},
setGlobalState: async (_, key: string, value: any) => {
return globalStateStorage.set(key, value);
},
delGlobalState: async (_, key: string) => {
return globalStateStorage.del(key);
},
clearGlobalState: async () => {
return globalStateStorage.clear();
},
setGlobalCache: async (_, key: string, value: any) => {
return globalCacheStorage.set(key, value);
},
delGlobalCache: async (_, key: string) => {
return globalCacheStorage.del(key);
},
clearGlobalCache: async () => {
return globalCacheStorage.clear();
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,2 @@
export { sharedStorageEvents } from './events';
export { sharedStorageHandlers } from './handlers';

View File

@@ -0,0 +1,139 @@
import fs from 'node:fs';
import type { Memento } from '@toeverything/infra';
import {
backoffRetry,
effect,
exhaustMapWithTrailing,
fromPromise,
} from '@toeverything/infra';
import { debounceTime, EMPTY, mergeMap, Observable, timeout } from 'rxjs';
import { logger } from '../logger';
export class PersistentJSONFileStorage implements Memento {
data: Record<string, any> = {};
subscriptions: Map<string, Set<(p: any) => void>> = new Map();
subscriptionAll: Set<(p: Record<string, any>) => void> = new Set();
constructor(readonly filepath: string) {
try {
this.data = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
} catch (err) {
// ignore ENOENT error
if (
!(
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
)
) {
logger.error('failed to load file', err);
}
}
}
get<T>(key: string): T | undefined {
return this.data[key];
}
all(): Record<string, any> {
return this.data;
}
watch<T>(key: string): Observable<T | undefined> {
const subs = this.subscriptions.get(key) || new Set();
this.subscriptions.set(key, subs);
return new Observable<T | undefined>(subscriber => {
const sub = (p: any) => subscriber.next(p);
subs.add(sub);
return () => {
subs.delete(sub);
};
});
}
watchAll(): Observable<Record<string, unknown | undefined>> {
return new Observable<Record<string, unknown | undefined>>(subscriber => {
const sub = (p: Record<string, unknown | undefined>) =>
subscriber.next(p);
this.subscriptionAll.add(sub);
return () => {
this.subscriptionAll.delete(sub);
};
});
}
set<T>(key: string, value: T): void {
this.data[key] = value;
const subs = this.subscriptions.get(key) || new Set();
for (const sub of subs) {
sub(value);
}
for (const sub of this.subscriptionAll) {
sub({
[key]: this.data[key],
});
}
this.save();
}
del(key: string): void {
delete this.data[key];
const subs = this.subscriptions.get(key) || new Set();
for (const sub of subs) {
sub(undefined);
}
for (const sub of this.subscriptionAll) {
sub({
[key]: undefined,
});
}
this.save();
}
clear(): void {
const oldData = this.data;
this.data = {};
for (const [_, subs] of this.subscriptions) {
for (const sub of subs) {
sub(undefined);
}
}
for (const sub of this.subscriptionAll) {
sub(
Object.fromEntries(
Object.entries(oldData).map(([key]) => [key, undefined])
)
);
}
this.save();
}
keys(): string[] {
return Object.keys(this.data);
}
save = effect(
debounceTime(1000),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
try {
await fs.promises.writeFile(
this.filepath,
JSON.stringify(this.data),
'utf-8'
);
} catch (err) {
logger.error(`failed to save file, ${this.filepath}`, err);
}
}).pipe(
timeout(5000),
backoffRetry({
count: Infinity,
}),
mergeMap(() => EMPTY)
);
})
);
dispose() {
this.save.unsubscribe();
}
}

View File

@@ -0,0 +1,13 @@
import path from 'node:path';
import { app } from 'electron';
import { PersistentJSONFileStorage } from './json-file';
export const globalStateStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'global-state.json')
);
export const globalCacheStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'global-cache.json')
);

View File

@@ -1,12 +1,14 @@
import { contextBridge } from 'electron';
import { affine, appInfo, getElectronAPIs } from './electron-api';
import { sharedStorage } from './shared-storage';
const { apis, events } = getElectronAPIs();
contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
contextBridge.exposeInMainWorld('sharedStorage', sharedStorage);
try {
contextBridge.exposeInMainWorld('affine', affine);

View File

@@ -0,0 +1,87 @@
import { MemoryMemento } from '@toeverything/infra';
import { ipcRenderer } from 'electron';
const initialGlobalState = ipcRenderer.sendSync(
'sharedStorage:getAllGlobalState'
);
const initialGlobalCache = ipcRenderer.sendSync(
'sharedStorage:getAllGlobalCache'
);
function invokeWithCatch(key: string, ...args: any[]) {
ipcRenderer.invoke(key, ...args).catch(err => {
console.error(`Failed to invoke ${key}`, err);
});
}
function createSharedStorageApi(
init: Record<string, any>,
event: string,
api: {
del: string;
clear: string;
set: string;
}
) {
const memory = new MemoryMemento();
memory.setAll(init);
ipcRenderer.on(`sharedStorage:${event}`, (_event, updates) => {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
memory.del(key);
} else {
memory.set(key, value);
}
}
});
return {
del(key: string) {
memory.del(key);
invokeWithCatch(`sharedStorage:${api.del}`, key);
},
clear() {
memory.clear();
invokeWithCatch(`sharedStorage:${api.clear}`);
},
get<T>(key: string): T | undefined {
return memory.get(key);
},
keys() {
return memory.keys();
},
set(key: string, value: unknown) {
memory.set(key, value);
invokeWithCatch(`sharedStorage:${api.set}`, key, value);
},
watch<T>(key: string, cb: (i: T | undefined) => void): () => void {
const subscription = memory.watch(key).subscribe(i => cb(i as T));
return () => subscription.unsubscribe();
},
};
}
export const globalState = createSharedStorageApi(
initialGlobalState,
'onGlobalStateChanged',
{
clear: 'clearGlobalState',
del: 'delGlobalState',
set: 'setGlobalState',
}
);
export const globalCache = createSharedStorageApi(
initialGlobalCache,
'onGlobalCacheChanged',
{
clear: 'clearGlobalCache',
del: 'delGlobalCache',
set: 'setGlobalCache',
}
);
export const sharedStorage = {
globalState,
globalCache,
};