mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-10-25 17:36:32 +08:00
fix(wcdb): allow partial third-party query providers
This commit is contained in:
1 parent
837697ba26
commit
e707a07945
5 files changed
+415
-32
No files matched your search
@@ -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
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, /当前数据服务版本不支持/)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user