From e707a079457f6ddba8ddfe565f1d6608a20b6961 Mon Sep 17 00:00:00 2001 From: elilitokirin1111-code Date: Thu, 8 Oct 2026 18:10:20 +0800 Subject: [PATCH] fix(wcdb): allow partial third-party query providers --- .github/workflows/wcdb-compatibility.yml | 38 +++ docs/third-party-components.md | 14 +- electron/services/wcdbCore.ts | 95 +++++-- package.json | 1 + scripts/check-wcdb-optional-exports.cjs | 299 +++++++++++++++++++++++ 5 files changed, 415 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/wcdb-compatibility.yml create mode 100644 scripts/check-wcdb-optional-exports.cjs diff --git a/.github/workflows/wcdb-compatibility.yml b/.github/workflows/wcdb-compatibility.yml new file mode 100644 index 0000000..f00f7e0 --- /dev/null +++ b/.github/workflows/wcdb-compatibility.yml @@ -0,0 +1,38 @@ +name: WCDB compatibility + +on: + pull_request: + paths: + - 'electron/services/wcdbCore.ts' + - 'electron/utils/pathUtils.ts' + - 'scripts/check-wcdb-optional-exports.cjs' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/wcdb-compatibility.yml' + push: + paths: + - 'electron/services/wcdbCore.ts' + - 'electron/utils/pathUtils.ts' + - 'scripts/check-wcdb-optional-exports.cjs' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/wcdb-compatibility.yml' + +permissions: + contents: read + +jobs: + regression: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - run: npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund + - run: npm run test:wcdb + - run: npm run typecheck diff --git a/docs/third-party-components.md b/docs/third-party-components.md index c0f5886..2e4ef57 100644 --- a/docs/third-party-components.md +++ b/docs/third-party-components.md @@ -13,11 +13,13 @@ WeFlow 不再内置任何用于读取/解密本地数据的原生组件。以下 加载流程(见 `electron/services/wcdbCore.ts` 的 `initialize()`): 1. `koffi.load(wcdbLibPath)`。 -2. 依次尝试绑定下列导出函数;某个符号不存在时该功能会被跳过(返回 `null`),不影响其余功能。 - 第一个必须存在的函数是 `wcdb_open_account`,其余按需实现即可。 +2. 依次尝试绑定下列导出函数;可选符号不存在时该功能会被跳过(返回 `null`),不影响其余功能。 + `wcdb_init`、`wcdb_shutdown`、`wcdb_open_account`、`wcdb_close_account` 和 + `wcdb_free_string` 必须存在,用于初始化、账号生命周期和返回字符串的释放; + 查询、统计、媒体、日志等功能接口按需实现即可。 完整函数签名列表(`int32` 返回值均为状态码,`0` 表示成功;`_Out_ void** outJson` 类参数 -通过 `wcdb_free_string` 释放调用方分配的字符串内存): +由组件分配字符串内存,调用方通过 `wcdb_free_string` 释放): ``` int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle) @@ -117,8 +119,10 @@ void wcdb_cloud_stop() void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen) ``` -`wcdb_open_account` 之外全部可选:缺失的符号对应功能会在应用里表现为"不可用",不影响 -其他已实现的功能。JSON 载荷/出参的字段命名可参照 `electron/services/wcdbCore.ts` 里 +上述五个生命周期与内存管理接口之外均为可选:缺失的符号对应功能会在应用里表现为"不可用", +不影响其他已实现的功能。消息游标需要同时提供 `wcdb_open_message_cursor`、 +`wcdb_fetch_message_batch` 和 `wcdb_close_message_cursor`,以保证已打开的游标可以读取和释放。 +JSON 载荷/出参的字段命名可参照 `electron/services/wcdbCore.ts` 里 每个函数调用点前后对返回值的解析逻辑。 ## 2. 媒体解密插件(`imageNativeAddonPath`) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 88fb527..640fac1 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -802,6 +802,14 @@ export class WcdbCore { return counts } + private bindOptionalExport(signature: string): any { + try { + return this.lib.func(signature) + } catch { + return null + } + } + /** * 初始化 WCDB */ @@ -919,7 +927,7 @@ export class WcdbCore { this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)') // wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json) - this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)') + this.wcdbGetSessions = this.bindOptionalExport('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)') // wcdb_status wcdb_mark_all_sessions_read(wcdb_handle handle, char** out_error) try { @@ -936,22 +944,22 @@ export class WcdbCore { } // wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json) - this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)') + this.wcdbGetMessages = this.bindOptionalExport('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)') // wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count) - this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)') + this.wcdbGetMessageCount = this.bindOptionalExport('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)') // wcdb_status wcdb_get_message_by_svrid(wcdb_handle handle, const char* session_id, const char* svrid, char** out_json) - this.wcdbGetMessageByServerId = this.lib.func('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)') + this.wcdbGetMessageByServerId = this.bindOptionalExport('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)') // wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json) - this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + this.wcdbGetDisplayNames = this.bindOptionalExport('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)') // wcdb_status wcdb_get_avatar_urls(wcdb_handle handle, const char* usernames_json, char** out_json) - this.wcdbGetAvatarUrls = this.lib.func('int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + this.wcdbGetAvatarUrls = this.bindOptionalExport('int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)') // wcdb_status wcdb_get_group_member_count(wcdb_handle handle, const char* chatroom_id, int32_t* out_count) - this.wcdbGetGroupMemberCount = this.lib.func('int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* outCount)') + this.wcdbGetGroupMemberCount = this.bindOptionalExport('int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* outCount)') // wcdb_status wcdb_get_group_member_counts(wcdb_handle handle, const char* chatroom_ids_json, char** out_json) try { @@ -961,7 +969,7 @@ export class WcdbCore { } // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) - this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') + this.wcdbGetGroupMembers = this.bindOptionalExport('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') // wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json) try { @@ -971,13 +979,13 @@ export class WcdbCore { } // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) - this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') + this.wcdbGetMessageTables = this.bindOptionalExport('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') // wcdb_status wcdb_get_message_meta(wcdb_handle handle, const char* db_path, const char* table_name, int32_t limit, int32_t offset, char** out_json) - this.wcdbGetMessageMeta = this.lib.func('int32 wcdb_get_message_meta(int64 handle, const char* dbPath, const char* tableName, int32 limit, int32 offset, _Out_ void** outJson)') + this.wcdbGetMessageMeta = this.bindOptionalExport('int32 wcdb_get_message_meta(int64 handle, const char* dbPath, const char* tableName, int32 limit, int32 offset, _Out_ void** outJson)') // wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json) - this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') + this.wcdbGetContact = this.bindOptionalExport('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') // wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json) try { @@ -1013,10 +1021,10 @@ export class WcdbCore { } // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) - this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') + this.wcdbGetMessageTableStats = this.bindOptionalExport('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') // wcdb_status wcdb_get_aggregate_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - this.wcdbGetAggregateStats = this.lib.func('int32 wcdb_get_aggregate_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') + this.wcdbGetAggregateStats = this.bindOptionalExport('int32 wcdb_get_aggregate_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') // wcdb_status wcdb_get_available_years(wcdb_handle handle, const char* session_ids_json, char** out_json) try { @@ -1075,7 +1083,7 @@ export class WcdbCore { } // wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) - this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') + this.wcdbOpenMessageCursor = this.bindOptionalExport('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') // Optional on older platform DLLs. It changes only the projection of the // cursor that was just opened; it never allocates another cursor. @@ -1086,19 +1094,16 @@ export class WcdbCore { } // wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more) - this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)') + this.wcdbFetchMessageBatch = this.bindOptionalExport('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)') // wcdb_status wcdb_close_message_cursor(wcdb_handle handle, wcdb_cursor cursor) - this.wcdbCloseMessageCursor = this.lib.func('int32 wcdb_close_message_cursor(int64 handle, int64 cursor)') - - // wcdb_status wcdb_get_logs(char** out_json) - this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') + this.wcdbCloseMessageCursor = this.bindOptionalExport('int32 wcdb_close_message_cursor(int64 handle, int64 cursor)') // wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json) - this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') + this.wcdbExecQuery = this.bindOptionalExport('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') // wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) - this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') + this.wcdbGetEmoticonCdnUrl = this.bindOptionalExport('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') // wcdb_status wcdb_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption) try { @@ -1117,13 +1122,13 @@ export class WcdbCore { } // wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json) - this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') + this.wcdbListMessageDbs = this.bindOptionalExport('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') // wcdb_status wcdb_list_media_dbs(wcdb_handle handle, char** out_json) - this.wcdbListMediaDbs = this.lib.func('int32 wcdb_list_media_dbs(int64 handle, _Out_ void** outJson)') + this.wcdbListMediaDbs = this.bindOptionalExport('int32 wcdb_list_media_dbs(int64 handle, _Out_ void** outJson)') // wcdb_status wcdb_get_message_by_id(wcdb_handle handle, const char* session_id, int32 local_id, char** out_json) - this.wcdbGetMessageById = this.lib.func('int32 wcdb_get_message_by_id(int64 handle, const char* sessionId, int32 localId, _Out_ void** outJson)') + this.wcdbGetMessageById = this.bindOptionalExport('int32 wcdb_get_message_by_id(int64 handle, const char* sessionId, int32 localId, _Out_ void** outJson)') // wcdb_status wcdb_get_db_status(wcdb_handle handle, char** out_json) try { @@ -2004,6 +2009,7 @@ export class WcdbCore { this.writeLog('getSessions skipped: not connected') return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetSessions) return { success: false, error: '当前数据服务版本不支持获取会话' } try { // 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞 await new Promise(resolve => setImmediate(resolve)) @@ -2132,6 +2138,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetMessages) return { success: false, error: '当前数据服务版本不支持获取消息' } try { const outPtr = [null as any] const result = this.wcdbGetMessages(this.handle, sessionId, limit, offset, outPtr) @@ -2182,6 +2189,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetMessageCount) return { success: false, error: '当前数据服务版本不支持获取消息数量' } try { const outCount = [0] const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) @@ -2201,6 +2209,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetMessageByServerId) return { success: false, error: '当前数据服务版本不支持按服务器 ID 查询消息' } try { const outPtr = [null as any] const result = this.wcdbGetMessageByServerId(this.handle, sessionId, svrid, outPtr) @@ -2237,6 +2246,7 @@ export class WcdbCore { return { success: true, counts: {} } } + if (!this.wcdbGetMessageCount) return { success: false, error: '当前数据服务版本不支持获取消息数量' } try { const counts: Record = {} for (let i = 0; i < normalizedSessionIds.length; i += 1) { @@ -3085,7 +3095,7 @@ export class WcdbCore { if (toFetch.length === 0) return { success: true, map: resultMap } - if (process.platform === 'darwin') { + if (this.wcdbExecQuery && (process.platform === 'darwin' || !this.wcdbGetDisplayNames)) { const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') const sql = `SELECT * FROM contact WHERE username IN (${inList})` const q = await this.execQuery('contact', null, sql) @@ -3116,6 +3126,12 @@ export class WcdbCore { return { success: true, map: resultMap } } + if (!this.wcdbGetDisplayNames) { + const error = '当前数据服务版本不支持获取昵称' + if (Object.keys(resultMap).length > 0) return { success: true, map: resultMap, error } + return { success: false, error } + } + // 让出控制权,避免阻塞事件循环 await new Promise(resolve => setImmediate(resolve)) @@ -3190,7 +3206,7 @@ export class WcdbCore { return { success: true, map: resultMap } } - if (process.platform === 'darwin') { + if (this.wcdbExecQuery && (process.platform === 'darwin' || !this.wcdbGetAvatarUrls)) { const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') const sql = `SELECT * FROM contact WHERE username IN (${inList})` const q = await this.execQuery('contact', null, sql) @@ -3218,6 +3234,12 @@ export class WcdbCore { return { success: true, map: resultMap } } + if (!this.wcdbGetAvatarUrls) { + const error = '当前数据服务版本不支持获取头像' + if (Object.keys(resultMap).length > 0) return { success: true, map: resultMap, error } + return { success: false, error } + } + // 让出控制权,避免阻塞事件循环 const handle = this.handle await new Promise(resolve => setImmediate(resolve)) @@ -3267,6 +3289,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetGroupMemberCount) return { success: false, error: '当前数据服务版本不支持获取群成员数量' } try { const outCount = [0] const result = this.wcdbGetGroupMemberCount(this.handle, chatroomId, outCount) @@ -3284,6 +3307,9 @@ export class WcdbCore { return { success: false, error: 'WCDB 未连接' } } if (chatroomIds.length === 0) return { success: true, map: {} } + if (!this.wcdbGetGroupMemberCounts && !this.wcdbGetGroupMemberCount) { + return { success: false, error: '当前数据服务版本不支持获取群成员数量' } + } if (!this.wcdbGetGroupMemberCounts) { const map: Record = {} for (const chatroomId of chatroomIds) { @@ -3313,6 +3339,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetGroupMembers) return { success: false, error: '当前数据服务版本不支持获取群成员' } try { const outPtr = [null as any] const result = this.wcdbGetGroupMembers(this.handle, chatroomId, outPtr) @@ -3354,6 +3381,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetMessageTables) return { success: false, error: '当前数据服务版本不支持获取消息表' } try { const outPtr = [null as any] const result = this.wcdbGetMessageTables(this.handle, sessionId, outPtr) @@ -3396,6 +3424,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetMessageTableStats) return { success: false, error: '当前数据服务版本不支持获取消息表统计' } try { const outPtr = [null as any] const result = this.wcdbGetMessageTableStats(this.handle, sessionId, outPtr) @@ -3415,6 +3444,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetMessageMeta) return { success: false, error: '当前数据服务版本不支持获取消息元数据' } try { const outPtr = [null as any] const result = this.wcdbGetMessageMeta(this.handle, dbPath, tableName, limit, offset, outPtr) @@ -3435,7 +3465,7 @@ export class WcdbCore { return { success: false, error: 'WCDB 未连接' } } try { - if (process.platform === 'darwin') { + if (this.wcdbExecQuery && (process.platform === 'darwin' || !this.wcdbGetContact)) { const safe = String(username || '').replace(/'/g, "''") const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` const q = await this.execQuery('contact', null, sql) @@ -3449,6 +3479,7 @@ export class WcdbCore { return { success: true, contact: row } } + if (!this.wcdbGetContact) return { success: false, error: '当前数据服务版本不支持获取联系人' } const outPtr = [null as any] const result = this.wcdbGetContact(this.handle, username, outPtr) if (result !== 0 || !outPtr[0]) { @@ -3749,6 +3780,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetAggregateStats) return { success: false, error: '当前数据服务版本不支持获取聚合统计' } try { const normalizedBegin = this.normalizeTimestamp(beginTimestamp) let normalizedEnd = this.normalizeTimestamp(endTimestamp) @@ -4009,6 +4041,9 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch || !this.wcdbCloseMessageCursor) { + return { success: false, error: '当前数据服务版本不支持完整消息游标接口' } + } try { const outCursor = [0] let result = this.wcdbOpenMessageCursor( @@ -4077,6 +4112,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbFetchMessageBatch) return { success: false, error: '当前数据服务版本不支持读取消息游标' } try { const outPtr = [null as any] const outHasMore = [0] @@ -4097,6 +4133,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbCloseMessageCursor) return { success: false, error: '当前数据服务版本不支持关闭消息游标' } try { const result = this.wcdbCloseMessageCursor(this.handle, cursor) if (result !== 0) { @@ -4189,6 +4226,7 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetEmoticonCdnUrl) return { success: false, error: '当前数据服务版本不支持获取表情 URL' } try { const outPtr = [null as any] const result = this.wcdbGetEmoticonCdnUrl(this.handle, dbPath, md5, outPtr) @@ -4247,6 +4285,7 @@ export class WcdbCore { async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbListMessageDbs) return { success: false, error: '当前数据服务版本不支持获取消息数据库列表' } try { const outPtr = [null as any] const result = this.wcdbListMessageDbs(this.handle, outPtr) @@ -4262,6 +4301,7 @@ export class WcdbCore { async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbListMediaDbs) return { success: false, error: '当前数据服务版本不支持获取媒体数据库列表' } try { const outPtr = [null as any] const result = this.wcdbListMediaDbs(this.handle, outPtr) @@ -4275,6 +4315,7 @@ export class WcdbCore { } } async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageById) return { success: false, error: '当前数据服务版本不支持按本地 ID 查询消息' } try { const outPtr = [null as any] const result = this.wcdbGetMessageById(this.handle, sessionId, localId, outPtr) diff --git a/package.json b/package.json index 3f0e581..9d18523 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "postinstall": "patch-package && electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", "rebuild": "electron-rebuild", "dev": "node scripts/prepare-electron-runtime.cjs && vite", + "test:wcdb": "node --test scripts/check-wcdb-optional-exports.cjs", "typecheck:renderer": "tsc --noEmit -p tsconfig.json", "typecheck:electron": "tsc --noEmit -p tsconfig.node.json", "typecheck": "npm run typecheck:renderer && npm run typecheck:electron", diff --git a/scripts/check-wcdb-optional-exports.cjs b/scripts/check-wcdb-optional-exports.cjs new file mode 100644 index 0000000..9b3bd76 --- /dev/null +++ b/scripts/check-wcdb-optional-exports.cjs @@ -0,0 +1,299 @@ +// Exercise the real TypeScript service without loading a native library or personal data. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const vm = require('node:vm') +const { after, test } = require('node:test') +const { transformSync } = require('esbuild') + +const sourcePath = process.env.WCDB_TEST_SOURCE || path.join(__dirname, '../electron/services/wcdbCore.ts') +const source = fs.readFileSync(sourcePath, 'utf8') +const compiled = transformSync(source, { loader: 'ts', format: 'cjs', target: 'node24' }).code +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'weflow-wcdb-exports-')) +const libraryPath = path.join(fixtureRoot, 'provider.dll') +const accountPath = path.join(fixtureRoot, 'synthetic-account') +const sessionPath = path.join(accountPath, 'db_storage', 'session', 'session.db') +fs.mkdirSync(path.dirname(sessionPath), { recursive: true }) +fs.writeFileSync(libraryPath, '') +fs.writeFileSync(sessionPath, '') +after(() => { + assert.equal(path.dirname(fixtureRoot), path.resolve(os.tmpdir())) + assert.ok(path.basename(fixtureRoot).startsWith('weflow-wcdb-exports-')) + fs.rmSync(fixtureRoot, { recursive: true, force: true }) +}) + +const required = ['wcdb_init', 'wcdb_shutdown', 'wcdb_open_account', 'wcdb_close_account', 'wcdb_free_string'] +const plain = (value) => JSON.parse(JSON.stringify(value)) + +function makeCore(options = {}) { + const state = { bindings: [], calls: new Map(), allocations: [], freed: [] } + const pointer = (value) => { + const result = { value: typeof value === 'string' ? value : JSON.stringify(value), freed: false } + state.allocations.push(result) + return result + } + const native = { + wcdb_init: () => 0, + wcdb_shutdown: () => 0, + wcdb_open_account: (_db, _key, handle) => { handle[0] = 7; return 0 }, + wcdb_close_account: () => 0, + wcdb_free_string: (ptr) => { + assert.equal(ptr.freed, false, 'native strings must be freed only once') + ptr.freed = true + state.freed.push(ptr) + }, + ...(options.native || {}) + } + const omit = new Set(options.omit || []) + const koffi = { + load: (filename) => { + assert.equal(filename, libraryPath) + if (options.loadError) throw options.loadError + return { + func: (signature) => { + const name = signature.match(/\b([A-Za-z_][A-Za-z_0-9]*)\s*\(/)[1] + state.bindings.push({ name, signature }) + if (omit.has(name) || (!Object.hasOwn(native, name) && !options.complete)) { + throw new Error(`Missing export: ${name}`) + } + return (...args) => { + if (!state.calls.has(name)) state.calls.set(name, []) + state.calls.get(name).push(args) + return Object.hasOwn(native, name) ? native[name](...args) : 0 + } + } + } + }, + decode: (ptr) => { + assert.equal(ptr.freed, false, 'a string must be decoded before release') + return ptr.value + } + } + // Only service imports are available. Native modules are always replaced by the mock. + const mocks = { + koffi, + fzstd: { decompress: () => { throw new Error('Unexpected compressed fixture') } }, + '../utils/pathUtils': { expandHomePath: (value) => value }, + path: require('node:path'), + fs: require('node:fs'), + 'fs/promises': require('node:fs/promises'), + os: require('node:os') + } + const module = { exports: {} } + const context = vm.createContext({ + module, + exports: module.exports, + require: (name) => { + assert.ok(Object.hasOwn(mocks, name), `Unexpected module: ${name}`) + return mocks[name] + }, + process: { platform: options.platform || 'win32', env: {}, pid: process.pid }, + console: { error() {}, warn() {}, log() {} }, + Buffer, setImmediate, setInterval, clearInterval, setTimeout, clearTimeout + }) + new vm.Script(compiled, { filename: sourcePath }).runInContext(context) + const core = new module.exports.WcdbCore() + core.writeLog = () => {} + core.setLibPath(libraryPath) + return { core, state, pointer, native } +} + +async function ready(fixture) { + assert.equal(await fixture.core.initialize(), true) + fixture.core.handle = 7 + return fixture +} + +function jsonExport(fixture, value) { + return (...args) => { args.at(-1)[0] = fixture.pointer(value); return 0 } +} + +test('a provider with only the five lifecycle/allocation exports initializes and opens an account', async () => { + const { core, state } = makeCore() + assert.equal(await core.open(accountPath, 'synthetic-key'), true) + assert.equal(state.calls.get('wcdb_open_account')[0][0], sessionPath) + assert.equal(state.calls.get('wcdb_open_account')[0][1], 'synthetic-key') + assert.equal(core.handle, 7) + assert.equal(await core.initialize(), true) + assert.equal(state.calls.get('wcdb_init').length, 1) + core.close() + assert.equal(state.calls.get('wcdb_shutdown').length, 1) + assert.equal(core.handle, null) + assert.equal(core.initialized, false) +}) + +for (const symbol of required) { + test(`missing required export ${symbol} still rejects initialization`, async () => { + const { core } = makeCore({ omit: [symbol] }) + assert.equal(await core.initialize(), false) + assert.equal(core.initialized, false) + assert.ok(core.getLastInitError()) + }) +} + +test('native load errors and nonzero initialization status still fail', async () => { + for (const options of [{ loadError: new Error('Synthetic load failure') }, { complete: true, native: { wcdb_init: () => -101 } }]) { + const { core, state } = makeCore(options) + assert.equal(await core.initialize(), false) + assert.equal(core.initialized, false) + assert.ok(core.getLastInitError()) + if (options.native) assert.equal(state.calls.get('wcdb_init').length, 1) + } +}) + +const unavailableCalls = [ + ['getSessions', []], + ['getMessages', ['session', 10, 0]], + ['getMessageCount', ['session']], + ['getMessageByServerId', ['session', '123']], + ['getDisplayNames', [['alice']]], + ['getAvatarUrls', [['alice']]], + ['getGroupMemberCount', ['room']], + ['getGroupMembers', ['room']], + ['getMessageTables', ['session']], + ['getMessageMeta', ['synthetic.db', 'table', 10, 0]], + ['getContact', ['alice']], + ['getMessageTableStats', ['session']], + ['getAggregateStats', [['session']]], + ['openMessageCursor', ['session', 10, false, 0, 0]], + ['fetchMessageBatch', [1]], + ['closeMessageCursor', [1]], + ['execQuery', ['message', 'synthetic.db', 'SELECT 1'], /接口未就绪/], + ['getEmoticonCdnUrl', ['synthetic.db', 'md5']], + ['listMessageDbs', []], + ['listMediaDbs', []], + ['getMessageById', ['session', 1]], + ['getMessageCounts', [['session']]], + ['getGroupMemberCounts', [['room']]], + ['getLogs', [], /接口未就绪/] +] + +for (const [method, args, error = /当前数据服务版本不支持/] of unavailableCalls) { + test(`missing optional capability: ${method} fails clearly and preserves supported queries`, async () => { + const fixture = makeCore() + const supported = method === 'getSessions' ? 'getMessages' : 'getSessions' + fixture.native[supported === 'getMessages' ? 'wcdb_get_messages' : 'wcdb_get_sessions'] = jsonExport(fixture, []) + await ready(fixture) + const result = await fixture.core[method](...args) + assert.equal(result.success, false) + assert.match(result.error, error) + assert.doesNotMatch(result.error, /TypeError|not a function/) + assert.equal((await fixture.core[supported](...(supported === 'getMessages' ? ['session', 10, 0] : []))).success, true) + assert.equal(fixture.state.freed.length, 1) + }) +} + +test('the log export is looked up once and remains optional', async () => { + const fixture = await ready(makeCore()) + assert.equal(fixture.state.bindings.filter(({ name }) => name === 'wcdb_get_logs').length, 1) + await fixture.core.printLogs(true) + assert.equal(fixture.state.calls.has('wcdb_get_logs'), false) +}) + +for (const missing of ['wcdb_open_message_cursor', 'wcdb_fetch_message_batch', 'wcdb_close_message_cursor']) { + test(`an incomplete cursor group (${missing}) cannot allocate a cursor`, async () => { + const fixture = makeCore({ complete: true, omit: [missing] }) + fixture.native.wcdb_open_message_cursor = (...args) => { args.at(-1)[0] = 11; return 0 } + await ready(fixture) + const result = await fixture.core.openMessageCursor('session', 10, false, 0, 0) + assert.equal(result.success, false) + assert.match(result.error, /当前数据服务版本不支持/) + assert.equal(fixture.state.calls.has('wcdb_open_message_cursor'), false) + }) +} + +test('a complete provider preserves queries, counts, cursor behavior, and native string ownership', async () => { + const fixture = makeCore({ complete: true }) + const messages = [{ local_id: 1, content: 'Synthetic message' }] + fixture.native.wcdb_get_sessions = jsonExport(fixture, [{ username: 'session' }]) + fixture.native.wcdb_get_messages = jsonExport(fixture, messages) + fixture.native.wcdb_get_logs = jsonExport(fixture, ['Synthetic log']) + fixture.native.wcdb_get_message_count = (_handle, _session, out) => { out[0] = 1; return 0 } + fixture.native.wcdb_open_message_cursor = (...args) => { args.at(-1)[0] = 11; return 0 } + fixture.native.wcdb_fetch_message_batch = (_handle, cursor, out, hasMore) => { + assert.equal(cursor, 11) + out[0] = fixture.pointer(messages) + hasMore[0] = 1 + return 0 + } + await ready(fixture) + assert.deepEqual(plain(await fixture.core.getSessions()), { success: true, sessions: [{ username: 'session' }] }) + assert.deepEqual(plain(await fixture.core.getMessages('session', 10, 0)), { success: true, messages }) + assert.deepEqual(plain(await fixture.core.getMessageCount('session')), { success: true, count: 1 }) + assert.deepEqual(plain(await fixture.core.getLogs()), { success: true, logs: ['Synthetic log'] }) + assert.deepEqual(plain(await fixture.core.openMessageCursor('session', 10, false, 0, 0)), { success: true, cursor: 11 }) + assert.deepEqual(plain(await fixture.core.fetchMessageBatch(11)), { success: true, rows: messages, hasMore: true }) + assert.deepEqual(plain(await fixture.core.closeMessageCursor(11)), { success: true }) + assert.equal(fixture.state.allocations.length, 4) + assert.deepEqual(fixture.state.freed, fixture.state.allocations) + assert.equal(fixture.state.bindings.filter(({ name }) => name === 'wcdb_get_logs').length, 1) +}) + +test('a supported query failure retains its native error and does not disable other queries', async () => { + const fixture = makeCore({ native: { wcdb_get_sessions: () => -7 } }) + fixture.native.wcdb_get_messages = jsonExport(fixture, []) + await ready(fixture) + const result = await fixture.core.getSessions() + assert.equal(result.success, false) + assert.match(result.error, /-7/) + assert.equal((await fixture.core.getMessages('session', 10, 0)).success, true) +}) + +for (const platform of ['darwin', 'win32']) { + test(`${platform} nickname, avatar, and contact SQL fallbacks work without their dedicated exports`, async () => { + const fixture = makeCore({ platform }) + const contact = { username: 'alice', remark: 'Alice', big_head_img_url: 'https://example.invalid/alice.png' } + fixture.native.wcdb_exec_query = jsonExport(fixture, [contact]) + await ready(fixture) + assert.deepEqual(plain(await fixture.core.getDisplayNames(['alice'])), { success: true, map: { alice: 'Alice' } }) + assert.deepEqual(plain(await fixture.core.getAvatarUrls(['alice'])), { success: true, map: { alice: contact.big_head_img_url } }) + assert.deepEqual(plain(await fixture.core.getContact('alice')), { success: true, contact }) + assert.equal(fixture.state.calls.get('wcdb_exec_query').length, 3) + assert.equal(fixture.state.freed.length, 3) + }) +} + +test('macOS uses the dedicated nickname, avatar, and contact exports when SQL is absent', async () => { + const fixture = makeCore({ platform: 'darwin' }) + const contact = { username: 'alice', remark: 'Alice' } + const avatar = 'https://example.invalid/alice.png' + fixture.native.wcdb_get_display_names = jsonExport(fixture, { alice: 'Alice' }) + fixture.native.wcdb_get_avatar_urls = jsonExport(fixture, { alice: avatar }) + fixture.native.wcdb_get_contact = jsonExport(fixture, contact) + await ready(fixture) + assert.deepEqual(plain(await fixture.core.getDisplayNames(['alice'])), { success: true, map: { alice: 'Alice' } }) + assert.deepEqual(plain(await fixture.core.getAvatarUrls(['alice'])), { success: true, map: { alice: avatar } }) + assert.deepEqual(plain(await fixture.core.getContact('alice')), { success: true, contact }) + assert.equal(fixture.state.calls.has('wcdb_exec_query'), false) + assert.equal(fixture.state.freed.length, 3) +}) + +test('a missing Windows contact export can use the supported SQL query export', async () => { + const fixture = makeCore() + const contact = { username: 'alice' } + fixture.native.wcdb_exec_query = jsonExport(fixture, [contact]) + await ready(fixture) + assert.deepEqual(plain(await fixture.core.getContact('alice')), { success: true, contact }) +}) + +test('empty input and cached names/avatars remain useful when native capabilities are absent', async () => { + const fixture = await ready(makeCore()) + const { core } = fixture + core.displayNameCache.set('cached', { displayName: 'Cached name', updatedAt: Date.now() }) + core.avatarUrlCache.set('cached', { url: 'https://example.invalid/cached.png', updatedAt: Date.now() }) + for (const method of ['getDisplayNames', 'getAvatarUrls', 'getGroupMemberCounts']) { + assert.deepEqual(plain(await core[method]([])), { success: true, map: {} }) + } + assert.deepEqual(plain(await core.getMessageCounts([])), { success: true, counts: {} }) + for (const [method, expected] of [ + ['getDisplayNames', { cached: 'Cached name' }], + ['getAvatarUrls', { cached: 'https://example.invalid/cached.png' }] + ]) { + assert.deepEqual(plain(await core[method](['cached'])), { success: true, map: expected }) + const partial = await core[method](['cached', 'missing']) + assert.equal(partial.success, true) + assert.deepEqual(plain(partial.map), expected) + assert.match(partial.error, /当前数据服务版本不支持/) + } +})