fix(wcdb): allow partial third-party query providers

This commit is contained in:
elilitokirin1111-code committed 2026-10-08 18:10:20 +08:00
1 parent 837697ba26
commit e707a07945
5 files changed
+415 -32

No files matched your search

+38
View File
@@ -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
+9 -5
View File
@@ -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`)
+68 -27
View File
@@ -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<string, number> = {}
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<string, number> = {}
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)
+1
View File
@@ -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",
+299
View File
@@ -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, /当前数据服务版本不支持/)
}
})