@onyx-p/imlib-web 3.0.0 → 3.0.2

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/README.md CHANGED
@@ -1,14 +1,117 @@
1
- ## ac-imlib-web
1
+ # ac-imlib-web
2
2
 
3
+ Electron-only IM SDK with a Signal-style encrypted local message database.
3
4
 
4
- ### 安装
5
- ```
5
+ ## 安装
6
+
7
+ ```bash
6
8
  npm install @onyx-p/imlib-web --save
7
9
  ```
8
10
 
9
- ### 代码集成
11
+ The database runtime requires Electron 30 or newer. The local database uses
12
+ `@signalapp/sqlcipher`; review its AGPL-3.0-only license before distribution.
13
+
14
+ ## Electron 主进程
15
+
16
+ Initialize the database service after `app.whenReady()`. This creates one
17
+ primary writer worker and three reader workers. The primary connection owns all
18
+ writes and schema migrations.
19
+
20
+ ```js
21
+ const { app, BrowserWindow } = require('electron')
22
+ const {
23
+ initializeDatabaseMain
24
+ } = require('@onyx-p/imlib-web/main')
25
+
26
+ let database
27
+
28
+ app.whenReady().then(() => {
29
+ database = initializeDatabaseMain({
30
+ // Recommended: restrict IPC to your own renderer origin.
31
+ isTrustedEvent(event) {
32
+ return event.senderFrame?.url.startsWith('file://') === true
33
+ }
34
+ })
35
+
36
+ const window = new BrowserWindow({
37
+ webPreferences: {
38
+ contextIsolation: true,
39
+ nodeIntegration: false,
40
+ preload: require.resolve('@onyx-p/imlib-web/preload')
41
+ }
42
+ })
43
+ })
44
+ ```
45
+
46
+ If the application already has a preload script, load the database preload
47
+ entry from that script instead:
48
+
49
+ ```js
50
+ require('@onyx-p/imlib-web/preload')
10
51
  ```
11
- // ES
52
+
53
+ The preload exposes only `window.acimDatabase.request()`. Renderer code cannot
54
+ execute arbitrary SQL or invoke arbitrary Electron channels.
55
+
56
+ ## Renderer
57
+
58
+ The public IM API remains in the default package entry. Await user setup so the
59
+ SQLCipher database, schema migrations, and legacy import finish before message
60
+ synchronization starts.
61
+
62
+ ```js
12
63
  import * as ACIMLib from '@onyx-p/imlib-web'
64
+
65
+ ACIMLib.init({ appkey: 'your-app-key' })
66
+ await ACIMLib.setUserLogged(profile)
67
+ await ACIMLib.connect()
68
+ ```
69
+
70
+ ## 数据库与迁移
71
+
72
+ - Database files are stored below Electron's `userData/sql` directory.
73
+ - Account identifiers are hashed before being used as filenames.
74
+ - Each account receives an independent random 256-bit SQLCipher key.
75
+ - Keys are encrypted with Electron `safeStorage` before they are written to
76
+ `database-keys.json`.
77
+ - Schema changes run automatically through ordered `PRAGMA user_version`
78
+ migrations in the primary worker.
79
+ - SQLCipher runs in WAL mode with foreign keys enabled and full synchronous
80
+ durability.
81
+
82
+ On first open, the renderer looks for the previous IndexedDB database named
83
+ `im_message_cache_1_<appKey>_<userId>`. Existing `messages_new` and
84
+ `dialogStates_new` records are read in bounded batches, legacy content fields
85
+ are decrypted, and records are idempotently inserted into SQLCipher. Completion
86
+ is recorded only after every batch commits. The old IndexedDB database is kept
87
+ as a recovery copy.
88
+
89
+ Migration runs automatically. It can also be retried explicitly with progress:
90
+
91
+ ```js
92
+ const result = await ACIMLib.migrateLegacyDatabase(progress => {
93
+ console.log(
94
+ `Migrated ${progress.processedMessages} messages and ` +
95
+ `${progress.processedDialogStates} dialog states`
96
+ )
97
+ })
98
+
99
+ console.log(result)
100
+ // { migrated: true|false, messages: number, dialogStates: number }
13
101
  ```
14
102
 
103
+ If migration is interrupted, call the same API again. Imports use upserts, so
104
+ already committed batches are safe to repeat.
105
+
106
+ ## 消息搜索
107
+
108
+ 单会话搜索接口保持不变。全局搜索使用 Signal 风格的 FTS5 前缀匹配,
109
+ 按消息时间倒序返回,默认最多 500 条:
110
+
111
+ ```js
112
+ const result = await ACIMLib.searchMessages('项目进度')
113
+ console.log(result.data)
114
+
115
+ // 可选:限制返回数量
116
+ const recent = await ACIMLib.searchMessages('signal', 50)
117
+ ```
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+
3
+ var node_path = require('node:path');
4
+ var electron = require('electron');
5
+ var node_crypto = require('node:crypto');
6
+ var node_fs = require('node:fs');
7
+ var node_worker_threads = require('node:worker_threads');
8
+
9
+ class DatabaseKeyStore {
10
+ directory;
11
+ safeStorage;
12
+ filePath;
13
+ constructor(directory, safeStorage) {
14
+ this.directory = directory;
15
+ this.safeStorage = safeStorage;
16
+ this.filePath = node_path.join(directory, 'database-keys.json');
17
+ }
18
+ getOrCreate(accountId) {
19
+ const keys = this.readKeys();
20
+ const encrypted = keys[accountId];
21
+ if (encrypted) {
22
+ const key = this.safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
23
+ this.validateKey(key);
24
+ return key;
25
+ }
26
+ const key = node_crypto.randomBytes(32).toString('hex');
27
+ keys[accountId] = Buffer.from(this.safeStorage.encryptString(key)).toString('base64');
28
+ this.writeKeys(keys);
29
+ return key;
30
+ }
31
+ readKeys() {
32
+ try {
33
+ const parsed = JSON.parse(node_fs.readFileSync(this.filePath, 'utf8'));
34
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
35
+ throw new Error('Database key file must contain an object');
36
+ }
37
+ return parsed;
38
+ }
39
+ catch (error) {
40
+ if (error.code === 'ENOENT') {
41
+ return Object.create(null);
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+ writeKeys(keys) {
47
+ node_fs.mkdirSync(this.directory, { recursive: true });
48
+ const temporaryPath = `${this.filePath}.tmp`;
49
+ node_fs.writeFileSync(temporaryPath, JSON.stringify(keys), { mode: 0o600 });
50
+ node_fs.renameSync(temporaryPath, this.filePath);
51
+ }
52
+ validateKey(key) {
53
+ if (!/^[0-9a-f]{64}$/i.test(key)) {
54
+ throw new Error('Stored database key is invalid');
55
+ }
56
+ }
57
+ }
58
+
59
+ const DATABASE_CHANNEL = 'acim-database:request';
60
+ const READ_METHOD_NAMES = [
61
+ 'getMessages',
62
+ 'getLatestMessage',
63
+ 'getLatestMessages',
64
+ 'getDialogLoadedState',
65
+ 'getMessageById',
66
+ 'getMessageByUId',
67
+ 'getMessageIdRange',
68
+ 'searchMessages',
69
+ 'searchTextMessages',
70
+ 'getLegacyMigrationState'
71
+ ];
72
+ const WRITE_METHOD_NAMES = [
73
+ 'addMessages',
74
+ 'clearConversationCache',
75
+ 'clearAllCache',
76
+ 'removeMessagesByUId',
77
+ 'updateMessageReceiptStatus',
78
+ 'clearBurnAfterReadingExpiredMessages',
79
+ 'upsertMessage',
80
+ 'convertToRecallMessages',
81
+ 'importLegacyBatch',
82
+ 'completeLegacyMigration'
83
+ ];
84
+ const READ_METHODS = new Set(READ_METHOD_NAMES);
85
+ const WRITE_METHODS = new Set(WRITE_METHOD_NAMES);
86
+ function serializeDatabaseError(error) {
87
+ if (error instanceof Error) {
88
+ return {
89
+ name: error.name,
90
+ message: error.message,
91
+ stack: error.stack
92
+ };
93
+ }
94
+ return {
95
+ name: 'Error',
96
+ message: String(error),
97
+ stack: undefined
98
+ };
99
+ }
100
+
101
+ function isRecord(value) {
102
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
103
+ }
104
+ function isTopLevelSender(event) {
105
+ if (!isRecord(event)) {
106
+ return false;
107
+ }
108
+ const candidate = event;
109
+ return (candidate.senderFrame !== undefined &&
110
+ candidate.senderFrame === candidate.sender?.mainFrame);
111
+ }
112
+ function parseRequest(value) {
113
+ if (!isRecord(value) || typeof value.type !== 'string') {
114
+ throw new Error('Invalid database IPC request');
115
+ }
116
+ if (value.type === 'close') {
117
+ return { type: 'close' };
118
+ }
119
+ if (value.type === 'open') {
120
+ if (!isRecord(value.options) ||
121
+ typeof value.options.appKey !== 'string' ||
122
+ typeof value.options.userId !== 'string' ||
123
+ !value.options.appKey ||
124
+ !value.options.userId) {
125
+ throw new Error('Invalid database open options');
126
+ }
127
+ return {
128
+ type: 'open',
129
+ options: { appKey: value.options.appKey, userId: value.options.userId }
130
+ };
131
+ }
132
+ if (value.type === 'read' || value.type === 'write') {
133
+ const allowed = value.type === 'read' ? READ_METHODS : WRITE_METHODS;
134
+ if (typeof value.method !== 'string' ||
135
+ !allowed.has(value.method) ||
136
+ !Array.isArray(value.args)) {
137
+ throw new Error(`Invalid database ${value.type} request`);
138
+ }
139
+ return value;
140
+ }
141
+ throw new Error(`Unsupported database IPC request: ${value.type}`);
142
+ }
143
+ function registerDatabaseIpc({ ipcMain, service, isTrustedEvent = isTopLevelSender }) {
144
+ ipcMain.handle(DATABASE_CHANNEL, async (event, value) => {
145
+ try {
146
+ if (!isTrustedEvent(event)) {
147
+ throw new Error('Database IPC sender is not trusted');
148
+ }
149
+ const request = parseRequest(value);
150
+ let result;
151
+ if (request.type === 'open') {
152
+ result = await service.open(request.options);
153
+ }
154
+ else if (request.type === 'close') {
155
+ result = await service.close();
156
+ }
157
+ else if (request.type === 'read') {
158
+ result = await service.read(request.method, request.args);
159
+ }
160
+ else {
161
+ result = await service.write(request.method, request.args);
162
+ }
163
+ return { ok: true, value: result };
164
+ }
165
+ catch (error) {
166
+ return { ok: false, error: serializeDatabaseError(error) };
167
+ }
168
+ });
169
+ return () => ipcMain.removeHandler(DATABASE_CHANNEL);
170
+ }
171
+
172
+ class MainDatabaseService {
173
+ options;
174
+ workerCount;
175
+ pool = [];
176
+ accountId = null;
177
+ constructor(options) {
178
+ this.options = options;
179
+ this.workerCount = options.workerCount ?? 4;
180
+ if (this.workerCount < 2) {
181
+ throw new Error('Database service requires one writer and at least one reader');
182
+ }
183
+ }
184
+ async open(options) {
185
+ const accountId = node_crypto.createHash('sha256')
186
+ .update(`${options.appKey}:${options.userId}`)
187
+ .digest('hex');
188
+ if (this.accountId === accountId && this.pool.length > 0) {
189
+ return;
190
+ }
191
+ await this.close();
192
+ const sqlDirectory = node_path.join(this.options.userDataPath, 'sql');
193
+ node_fs.mkdirSync(sqlDirectory, { recursive: true });
194
+ const dbPath = node_path.join(sqlDirectory, `${accountId}.sqlite`);
195
+ const key = this.options.keyStore.getOrCreate(accountId);
196
+ try {
197
+ for (let index = 0; index < this.workerCount; index += 1) {
198
+ this.pool.push({ client: this.options.workerFactory(index), load: 0 });
199
+ }
200
+ await this.pool[0].client.request({
201
+ type: 'init',
202
+ dbPath,
203
+ key,
204
+ isPrimary: true
205
+ });
206
+ await Promise.all(this.pool.slice(1).map(entry => entry.client.request({ type: 'init', dbPath, key, isPrimary: false })));
207
+ this.accountId = accountId;
208
+ }
209
+ catch (error) {
210
+ await this.close();
211
+ throw error;
212
+ }
213
+ }
214
+ async read(method, args) {
215
+ this.assertOpen();
216
+ const readers = this.pool.slice(1);
217
+ const entry = readers.reduce((least, candidate) => candidate.load < least.load ? candidate : least);
218
+ entry.load += 1;
219
+ try {
220
+ return await entry.client.request({ type: 'call', access: 'read', method, args });
221
+ }
222
+ finally {
223
+ entry.load -= 1;
224
+ }
225
+ }
226
+ async write(method, args) {
227
+ this.assertOpen();
228
+ const primary = this.pool[0];
229
+ primary.load += 1;
230
+ try {
231
+ return await primary.client.request({ type: 'call', access: 'write', method, args });
232
+ }
233
+ finally {
234
+ primary.load -= 1;
235
+ }
236
+ }
237
+ async close() {
238
+ const entries = this.pool.splice(0);
239
+ this.accountId = null;
240
+ await Promise.all(entries.map(async (entry) => {
241
+ try {
242
+ await entry.client.request({ type: 'close' });
243
+ }
244
+ finally {
245
+ await entry.client.close();
246
+ }
247
+ }));
248
+ }
249
+ assertOpen() {
250
+ if (!this.accountId || this.pool.length === 0) {
251
+ throw new Error('Database service is not open');
252
+ }
253
+ }
254
+ }
255
+ class ThreadWorkerClient {
256
+ worker;
257
+ sequence = 0;
258
+ pending = new Map();
259
+ constructor(worker) {
260
+ this.worker = worker;
261
+ worker.on('message', (response) => {
262
+ const pending = this.pending.get(response.sequence);
263
+ if (!pending) {
264
+ return;
265
+ }
266
+ this.pending.delete(response.sequence);
267
+ if (response.ok) {
268
+ pending.resolve(response.value);
269
+ }
270
+ else {
271
+ const error = new Error(response.error?.message ?? 'Database worker failed');
272
+ error.name = response.error?.name ?? 'Error';
273
+ error.stack = response.error?.stack;
274
+ pending.reject(error);
275
+ }
276
+ });
277
+ const rejectAll = (error) => {
278
+ for (const pending of this.pending.values()) {
279
+ pending.reject(error);
280
+ }
281
+ this.pending.clear();
282
+ };
283
+ worker.on('error', rejectAll);
284
+ worker.on('exit', code => {
285
+ if (code !== 0) {
286
+ rejectAll(new Error(`Database worker exited with code ${code}`));
287
+ }
288
+ });
289
+ }
290
+ request(request) {
291
+ const sequence = ++this.sequence;
292
+ return new Promise((resolve, reject) => {
293
+ this.pending.set(sequence, { resolve, reject });
294
+ const wrapped = { sequence, request };
295
+ this.worker.postMessage(wrapped);
296
+ });
297
+ }
298
+ async close() {
299
+ await this.worker.terminate();
300
+ }
301
+ }
302
+ function createThreadWorkerFactory(workerPath) {
303
+ return () => new ThreadWorkerClient(new node_worker_threads.Worker(workerPath));
304
+ }
305
+
306
+ function initializeDatabaseMain(options = {}) {
307
+ if (!electron.safeStorage.isEncryptionAvailable()) {
308
+ throw new Error('Electron safeStorage is unavailable; refusing to persist a database key');
309
+ }
310
+ const userDataPath = options.userDataPath ?? electron.app.getPath('userData');
311
+ const workerPath = options.workerPath ?? node_path.join(__dirname, 'worker.cjs');
312
+ const keyStore = new DatabaseKeyStore(node_path.join(userDataPath, 'sql'), electron.safeStorage);
313
+ const service = new MainDatabaseService({
314
+ userDataPath,
315
+ keyStore,
316
+ workerFactory: createThreadWorkerFactory(workerPath)
317
+ });
318
+ const removeIpcHandler = registerDatabaseIpc({
319
+ ipcMain: electron.ipcMain,
320
+ service,
321
+ isTrustedEvent: options.isTrustedEvent
322
+ });
323
+ const closeBeforeQuit = () => {
324
+ void service.close();
325
+ };
326
+ electron.app.once('before-quit', closeBeforeQuit);
327
+ return {
328
+ service,
329
+ async dispose() {
330
+ removeIpcHandler();
331
+ electron.app.removeListener('before-quit', closeBeforeQuit);
332
+ await service.close();
333
+ }
334
+ };
335
+ }
336
+
337
+ exports.DatabaseKeyStore = DatabaseKeyStore;
338
+ exports.MainDatabaseService = MainDatabaseService;
339
+ exports.ThreadWorkerClient = ThreadWorkerClient;
340
+ exports.createThreadWorkerFactory = createThreadWorkerFactory;
341
+ exports.initializeDatabaseMain = initializeDatabaseMain;
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ var electron = require('electron');
4
+
5
+ const DATABASE_GLOBAL = 'acimDatabase';
6
+ const DATABASE_CHANNEL = 'acim-database:request';
7
+ const READ_METHOD_NAMES = [
8
+ 'getMessages',
9
+ 'getLatestMessage',
10
+ 'getLatestMessages',
11
+ 'getDialogLoadedState',
12
+ 'getMessageById',
13
+ 'getMessageByUId',
14
+ 'getMessageIdRange',
15
+ 'searchMessages',
16
+ 'searchTextMessages',
17
+ 'getLegacyMigrationState'
18
+ ];
19
+ const WRITE_METHOD_NAMES = [
20
+ 'addMessages',
21
+ 'clearConversationCache',
22
+ 'clearAllCache',
23
+ 'removeMessagesByUId',
24
+ 'updateMessageReceiptStatus',
25
+ 'clearBurnAfterReadingExpiredMessages',
26
+ 'upsertMessage',
27
+ 'convertToRecallMessages',
28
+ 'importLegacyBatch',
29
+ 'completeLegacyMigration'
30
+ ];
31
+ new Set(READ_METHOD_NAMES);
32
+ new Set(WRITE_METHOD_NAMES);
33
+
34
+ function exposeDatabaseBridge(contextBridge, ipcRenderer) {
35
+ const bridge = {
36
+ request(request) {
37
+ return ipcRenderer.invoke(DATABASE_CHANNEL, request);
38
+ }
39
+ };
40
+ contextBridge.exposeInMainWorld(DATABASE_GLOBAL, bridge);
41
+ }
42
+
43
+ exposeDatabaseBridge(electron.contextBridge, electron.ipcRenderer);
44
+
45
+ exports.exposeDatabaseBridge = exposeDatabaseBridge;