mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat(electron): shared storage (#7492)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
25
packages/frontend/electron/src/main/shared-storage/events.ts
Normal file
25
packages/frontend/electron/src/main/shared-storage/events.ts
Normal 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>;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { sharedStorageEvents } from './events';
|
||||
export { sharedStorageHandlers } from './handlers';
|
||||
139
packages/frontend/electron/src/main/shared-storage/json-file.ts
Normal file
139
packages/frontend/electron/src/main/shared-storage/json-file.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
87
packages/frontend/electron/src/preload/shared-storage.ts
Normal file
87
packages/frontend/electron/src/preload/shared-storage.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user