feat: 解析mmkv数据,优化账号选择体验

This commit is contained in:
H3CoF6
2026-03-16 07:30:08 +08:00
parent 1f676254a9
commit 579b63b036
5 changed files with 264 additions and 39 deletions

View File

@@ -1,13 +1,90 @@
import { join, basename } from 'path' import { join, basename } from 'path'
import { existsSync, readdirSync, statSync } from 'fs' import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { createDecipheriv } from 'crypto'
export interface WxidInfo { export interface WxidInfo {
wxid: string wxid: string
modifiedTime: number modifiedTime: number
nickname?: string
avatarUrl?: string
} }
export class DbPathService { export class DbPathService {
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
let value = 0;
let length = 0;
let shift = 0;
while (offset < buf.length && shift < 32) {
const b = buf[offset++];
value |= (b & 0x7f) << shift;
length++;
if ((b & 0x80) === 0) break;
shift += 7;
}
return { value, length };
}
private extractMmkvString(buf: Buffer, keyName: string): string {
const keyBuf = Buffer.from(keyName, 'utf8');
const idx = buf.indexOf(keyBuf);
if (idx === -1) return '';
try {
let offset = idx + keyBuf.length;
const v1 = this.readVarint(buf, offset);
offset += v1.length;
const v2 = this.readVarint(buf, offset);
offset += v2.length;
// 合理性检查
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
return buf.toString('utf8', offset, offset + v2.value);
}
} catch { }
return '';
}
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
try {
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
if (!existsSync(configPath)) return null;
const fullData = readFileSync(configPath);
if (fullData.length <= 4) return null;
const encryptedData = fullData.subarray(4);
const key = Buffer.alloc(16, 0);
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码iv更是不重要
const iv = Buffer.alloc(16, 0);
const decipher = createDecipheriv('aes-128-cfb', key, iv);
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
if (!avatarUrl && decrypted.includes('http')) {
const httpIdx = decrypted.indexOf('http');
const nullIdx = decrypted.indexOf(0x00, httpIdx);
if (nullIdx !== -1) {
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
}
}
if (wxid || nickname) {
return { wxid, nickname, avatarUrl };
}
return null;
} catch (e) {
console.error('解析 global_config 失败:', e);
return null;
}
}
/** /**
* 自动检测微信数据库根目录 * 自动检测微信数据库根目录
*/ */
@@ -135,21 +212,16 @@ export class DbPathService {
for (const entry of entries) { for (const entry of entries) {
const entryPath = join(rootPath, entry) const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync> let stat: ReturnType<typeof statSync>
try { try { stat = statSync(entryPath) } catch { continue }
stat = statSync(entryPath)
} catch {
continue
}
if (!stat.isDirectory()) continue if (!stat.isDirectory()) continue
const lower = entry.toLowerCase() const lower = entry.toLowerCase()
if (lower === 'all_users') continue if (lower === 'all_users') continue
if (!entry.includes('_')) continue if (!entry.includes('_')) continue
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs }) wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
} }
} }
if (wxids.length === 0) { if (wxids.length === 0) {
const rootName = basename(rootPath) const rootName = basename(rootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') { if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
@@ -159,12 +231,25 @@ export class DbPathService {
} }
} catch { } } catch { }
return wxids.sort((a, b) => { const sorted = wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid) return a.wxid.localeCompare(b.wxid)
}) });
const globalInfo = this.parseGlobalConfig(rootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
w.nickname = globalInfo.nickname;
w.avatarUrl = globalInfo.avatarUrl;
}
}
}
return sorted;
} }
/** /**
* 扫描 wxid 列表 * 扫描 wxid 列表
*/ */
@@ -187,10 +272,21 @@ export class DbPathService {
} }
} catch { } } catch { }
return wxids.sort((a, b) => { const sorted = wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid) return a.wxid.localeCompare(b.wxid)
}) });
const globalInfo = this.parseGlobalConfig(rootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
w.nickname = globalInfo.nickname;
w.avatarUrl = globalInfo.avatarUrl;
}
}
}
return sorted;
} }
/** /**

View File

@@ -1705,7 +1705,7 @@
.wxid-dialog-item { .wxid-dialog-item {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 4px; gap: 4px;
padding: 14px 16px; padding: 14px 16px;
border-radius: 10px; border-radius: 10px;
@@ -1743,6 +1743,66 @@
justify-content: flex-end; justify-content: flex-end;
} }
.wxid-profile-row {
display: flex;
align-items: center;
gap: 12px;
.wxid-avatar {
width: 38px;
height: 38px;
border-radius: 8px;
object-fit: cover;
}
.wxid-avatar-fallback {
width: 38px;
height: 38px;
border-radius: 8px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.wxid-info-col {
display: flex;
flex-direction: column;
gap: 2px;
}
}
.wxid-profile-mini {
display: flex;
align-items: center;
gap: 10px;
.wxid-avatar {
width: 26px;
height: 26px;
border-radius: 6px;
object-fit: cover;
}
.wxid-avatar-fallback {
width: 26px;
height: 26px;
border-radius: 6px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.wxid-info-col {
display: flex;
flex-direction: column;
align-items: flex-start;
}
}
// 通知过滤双列表容器 // 通知过滤双列表容器
.notification-filter-container { .notification-filter-container {
display: grid; display: grid;

View File

@@ -10,7 +10,7 @@ import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X, UserRound
} from 'lucide-react' } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
@@ -34,6 +34,8 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
interface WxidOption { interface WxidOption {
wxid: string wxid: string
modifiedTime: number modifiedTime: number
nickname?: string
avatarUrl?: string
} }
interface SettingsPageProps { interface SettingsPageProps {
@@ -2132,14 +2134,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
<div className="wxid-dialog-list"> <div className="wxid-dialog-list">
{wxidOptions.map((opt) => ( {wxidOptions.map((opt) => (
<div <div
key={opt.wxid} key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`} className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)} onClick={() => handleSelectWxid(opt.wxid)}
> >
<span className="wxid-id">{opt.wxid}</span> <div className="wxid-profile-row">
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span> {opt.avatarUrl ? (
</div> <img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
) : (
<div className="wxid-avatar-fallback"><UserRound size={18}/></div>
)}
<div className="wxid-info-col">
<span className="wxid-id">{opt.nickname || opt.wxid}</span>
{opt.nickname && <span className="wxid-date">{opt.wxid}</span>}
</div>
</div>
<span className="wxid-date" style={{marginLeft: 'auto'}}> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))} ))}
</div> </div>
<div className="wxid-dialog-footer"> <div className="wxid-dialog-footer">

View File

@@ -488,6 +488,48 @@
white-space: nowrap; white-space: nowrap;
} }
.wxid-profile {
display: flex;
align-items: center;
gap: 10px;
}
.wxid-avatar {
width: 32px;
height: 32px;
border-radius: 6px;
object-fit: cover;
}
.wxid-avatar-fallback {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.wxid-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.wxid-nickname {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.wxid-sub {
font-size: 11px;
color: var(--text-tertiary);
}
.field-with-toggle { .field-with-toggle {
position: relative; position: relative;
} }

View File

@@ -47,7 +47,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [imageAesKey, setImageAesKey] = useState('') const [imageAesKey, setImageAesKey] = useState('')
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([]) const [wxidOptions, setWxidOptions] = useState<Array<{
avatarUrl?: string;
nickname?: string;
wxid: string;
modifiedTime: number
}>>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidSelectRef = useRef<HTMLDivElement>(null) const wxidSelectRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -688,22 +693,32 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
{showWxidSelect && wxidOptions.length > 0 && ( {showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown"> <div className="wxid-dropdown">
{wxidOptions.map((opt) => ( {wxidOptions.map((opt) => (
<button <button
key={opt.wxid} key={opt.wxid}
type="button" type="button"
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`} className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => { onClick={() => {
setWxid(opt.wxid) setWxid(opt.wxid)
setShowWxidSelect(false) setShowWxidSelect(false)
}} }}
> >
<span className="wxid-name">{opt.wxid}</span> <div className="wxid-profile">
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span> {opt.avatarUrl ? (
</button> <img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
))} ) : (
</div> <div className="wxid-avatar-fallback"><UserRound size={14}/></div>
)}
<div className="wxid-info">
<span className="wxid-nickname">{opt.nickname || opt.wxid}</span>
{opt.nickname && <span className="wxid-sub">{opt.wxid}</span>}
</div>
</div>
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
</button>
))}
</div>
)} )}
</div> </div>