Merge branch 'hicccc77:dev' into dev

This commit is contained in:
xuncha
2026-03-14 14:50:17 +08:00
committed by GitHub
26 changed files with 440 additions and 176 deletions

View File

@@ -660,17 +660,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
nodeIntegration: false, nodeIntegration: false,
webSecurity: false // 允许加载本地文件 webSecurity: false // 允许加载本地文件
}, },
titleBarStyle: 'hidden', frame: false,
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
show: false, show: false,
backgroundColor: '#000000', backgroundColor: '#000000',
autoHideMenuBar: true autoHideMenuBar: true
}) })
setupCustomTitleBarWindow(win)
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
win.show() win.show()
}) })
@@ -975,6 +972,17 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('log:clear', async () => {
try {
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
await mkdir(dirname(logPath), { recursive: true })
await writeFile(logPath, '', 'utf8')
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
return exportCardDiagnosticsService.snapshot(options?.limit) return exportCardDiagnosticsService.snapshot(options?.limit)
}) })

View File

@@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
log: { log: {
getPath: () => ipcRenderer.invoke('log:getPath'), getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read'), read: () => ipcRenderer.invoke('log:read'),
clear: () => ipcRenderer.invoke('log:clear'),
debug: (data: any) => ipcRenderer.send('log:debug', data) debug: (data: any) => ipcRenderer.send('log:debug', data)
}, },

View File

@@ -359,8 +359,9 @@ class ChatService {
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
wcdbService.setMonitor((type, json) => { wcdbService.setMonitor((type, json) => {
this.handleSessionStatsMonitorChange(type, json) this.handleSessionStatsMonitorChange(type, json)
const windows = BrowserWindow.getAllWindows()
// 广播给所有渲染进程窗口 // 广播给所有渲染进程窗口
BrowserWindow.getAllWindows().forEach((win) => { windows.forEach((win) => {
if (!win.isDestroyed()) { if (!win.isDestroyed()) {
win.webContents.send('wcdb-change', { type, json }) win.webContents.send('wcdb-change', { type, json })
} }

View File

@@ -23,6 +23,7 @@ export class KeyServiceMac {
private machVmRegion: any = null private machVmRegion: any = null
private machVmReadOverwrite: any = null private machVmReadOverwrite: any = null
private machPortDeallocate: any = null private machPortDeallocate: any = null
private _needsElevation = false
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
@@ -49,6 +50,26 @@ export class KeyServiceMac {
throw new Error('xkey_helper not found') throw new Error('xkey_helper not found')
} }
private getImageScanHelperPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
throw new Error('image_scan_helper not found')
}
private getDylibPath(): string { private getDylibPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const candidates: string[] = [] const candidates: string[] = []
@@ -258,7 +279,7 @@ export class KeyServiceMac {
stdout += data stdout += data
stdoutBuf += data stdoutBuf += data
const parts = stdoutBuf.split(/\r?\n/) const parts = stdoutBuf.split(/\r?\n/)
stdoutBuf = parts.pop() || '' stdoutBuf = parts.pop()!
}) })
child.stderr.on('data', (chunk: Buffer | string) => { child.stderr.on('data', (chunk: Buffer | string) => {
@@ -266,7 +287,7 @@ export class KeyServiceMac {
stderr += data stderr += data
stderrBuf += data stderrBuf += data
const parts = stderrBuf.split(/\r?\n/) const parts = stderrBuf.split(/\r?\n/)
stderrBuf = parts.pop() || '' stderrBuf = parts.pop()!
for (const line of parts) processHelperLine(line.trim()) for (const line of parts) processHelperLine(line.trim())
}) })
@@ -337,13 +358,13 @@ export class KeyServiceMac {
const result = await execFileAsync('osascript', scriptLines.flatMap(line => ['-e', line]), { const result = await execFileAsync('osascript', scriptLines.flatMap(line => ['-e', line]), {
timeout: waitMs + 20_000 timeout: waitMs + 20_000
}) })
stdout = result.stdout || '' stdout = result.stdout
} catch (e: any) { } catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim() const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
throw new Error(msg || 'elevated helper execution failed') throw new Error(msg || 'elevated helper execution failed')
} }
const lines = String(stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean) const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
const last = lines[lines.length - 1] const last = lines[lines.length - 1]
if (!last) throw new Error('elevated helper returned empty output') if (!last) throw new Error('elevated helper returned empty output')
@@ -614,6 +635,32 @@ export class KeyServiceMac {
ciphertext: Buffer, ciphertext: Buffer,
onProgress?: (message: string) => void onProgress?: (message: string) => void
): Promise<string | null> { ): Promise<string | null> {
// 优先通过 image_scan_helper 子进程调用
try {
const helperPath = this.getImageScanHelperPath()
const ciphertextHex = ciphertext.toString('hex')
// 1) 直接运行 helper有正式签名的 debugger entitlement 时可用)
if (!this._needsElevation) {
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
if (direct.key) return direct.key
if (direct.permissionError) {
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
this._needsElevation = true
onProgress?.('需要管理员权限,请在弹出的对话框中输入密码...')
}
}
// 2) 通过 osascript 以管理员权限运行 helperSIP 下 ad-hoc 签名无法获取 task_for_pid
if (this._needsElevation) {
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
if (elevated.key) return elevated.key
}
} catch (e: any) {
console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message)
}
// fallback: 直接通过 Mach API 扫描内存Electron 进程可能没有 task_for_pid 权限)
if (!this.ensureMachApis()) return null if (!this.ensureMachApis()) return null
const VM_PROT_READ = 0x1 const VM_PROT_READ = 0x1
@@ -708,6 +755,45 @@ export class KeyServiceMac {
} }
} }
private _spawnScanHelper(
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
): Promise<{ key: string | null; permissionError: boolean }> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof spawn>
if (elevated) {
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
child = spawn('osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
{ stdio: ['ignore', 'pipe', 'pipe'] })
} else {
child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] })
}
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
let stdout = '', stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
console.log(tag, chunk.toString().trim())
})
child.on('error', reject)
child.on('close', () => {
const permissionError = !elevated && stderr.includes('task_for_pid failed')
try {
const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
const last = lines[lines.length - 1]
if (!last) { resolve({ key: null, permissionError }); return }
const payload = JSON.parse(last)
resolve({
key: payload?.success && payload?.aesKey ? payload.aesKey : null,
permissionError
})
} catch {
resolve({ key: null, permissionError })
}
})
setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, elevated ? 60_000 : 30_000)
})
}
private async findWeChatPid(): Promise<number | null> { private async findWeChatPid(): Promise<number | null> {
const { execSync } = await import('child_process') const { execSync } = await import('child_process')
try { try {

View File

@@ -173,7 +173,6 @@ export class WcdbCore {
} }
} catch {} } catch {}
} }
this.connectMonitorPipe(pipePath) this.connectMonitorPipe(pipePath)
return true return true
} catch (e) { } catch (e) {
@@ -190,13 +189,18 @@ export class WcdbCore {
setTimeout(() => { setTimeout(() => {
if (!this.monitorCallback) return if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
})
let buffer = '' let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => { this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8') const rawChunk = data.toString('utf8')
const lines = buffer.split('\n') // macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包
const normalizedChunk = rawChunk
.replace(/\u0000/g, '\n')
.replace(/}\s*{/g, '}\n{')
buffer += normalizedChunk
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || '' buffer = lines.pop() || ''
for (const line of lines) { for (const line of lines) {
if (line.trim()) { if (line.trim()) {
@@ -208,9 +212,22 @@ export class WcdbCore {
} }
} }
} }
// 兜底:如果没有分隔符但已形成完整 JSON则直接上报
const tail = buffer.trim()
if (tail.startsWith('{') && tail.endsWith('}')) {
try {
const parsed = JSON.parse(tail)
this.monitorCallback?.(parsed.action || 'update', tail)
buffer = ''
} catch {
// 不可解析则继续等待下一块数据
}
}
}) })
this.monitorPipeClient.on('error', () => { this.monitorPipeClient.on('error', () => {
// 保持静默,与现有错误处理策略一致
}) })
this.monitorPipeClient.on('close', () => { this.monitorPipeClient.on('close', () => {

View File

@@ -136,7 +136,7 @@ export class WcdbService {
*/ */
setMonitor(callback: (type: string, json: string) => void): void { setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback; this.monitorListener = callback;
this.callWorker('setMonitor').catch(() => { }); this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
} }
/** /**

View File

@@ -20,15 +20,17 @@ if (parentPort) {
result = { success: true } result = { success: true }
break break
case 'setMonitor': case 'setMonitor':
core.setMonitor((type, json) => { {
const monitorOk = core.setMonitor((type, json) => {
parentPort!.postMessage({ parentPort!.postMessage({
id: -1, id: -1,
type: 'monitor', type: 'monitor',
payload: { type, json } payload: { type, json }
}) })
}) })
result = { success: true } result = { success: monitorOk }
break break
}
case 'testConnection': case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break break

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>

BIN
resources/image_scan_helper Executable file

Binary file not shown.

View File

@@ -0,0 +1,77 @@
/*
* image_scan_helper - 轻量包装程序
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
* 用法: image_scan_helper <pid> <ciphertext_hex>
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
#include <libgen.h>
#include <mach-o/dyld.h>
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
typedef void (*FreeStringFn)(const char* str);
int main(int argc, char* argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
return 1;
}
int pid = atoi(argv[1]);
const char* ciphertext_hex = argv[2];
if (pid <= 0) {
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
return 1;
}
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
char exe_path[4096];
uint32_t size = sizeof(exe_path);
if (_NSGetExecutablePath(exe_path, &size) != 0) {
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
return 1;
}
char* dir = dirname(exe_path);
char dylib_path[4096];
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
void* handle = dlopen(dylib_path, RTLD_LAZY);
if (!handle) {
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
return 1;
}
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
if (!scan_fn) {
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
dlclose(handle);
return 1;
}
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
const char* result = scan_fn(pid, ciphertext_hex);
if (result && strlen(result) > 0) {
/* 检查是否是错误 */
if (strncmp(result, "ERROR", 5) == 0) {
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
} else {
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
}
if (free_fn) free_fn(result);
} else {
printf("{\"success\":false,\"error\":\"no key found\"}\n");
}
dlclose(handle);
return 0;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
return () => { return () => {
removeListener() removeListener()
} }
} else {
} }
return () => { } return () => { }
}, []) }, [])

View File

@@ -36,6 +36,8 @@
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
.title-sidebar-toggle { .title-sidebar-toggle {
@@ -90,3 +92,55 @@
color: #fff; color: #fff;
} }
} }
.image-controls {
display: flex;
align-items: center;
gap: 8px;
margin-right: auto;
padding-left: 16px;
-webkit-app-region: no-drag;
button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
.scale-text {
min-width: 50px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.divider {
width: 1px;
height: 14px;
background: var(--border-color);
margin: 0 4px;
}
}

View File

@@ -7,13 +7,17 @@ interface TitleBarProps {
sidebarCollapsed?: boolean sidebarCollapsed?: boolean
onToggleSidebar?: () => void onToggleSidebar?: () => void
showWindowControls?: boolean showWindowControls?: boolean
customControls?: React.ReactNode
showLogo?: boolean
} }
function TitleBar({ function TitleBar({
title, title,
sidebarCollapsed = false, sidebarCollapsed = false,
onToggleSidebar, onToggleSidebar,
showWindowControls = true showWindowControls = true,
customControls,
showLogo = true
}: TitleBarProps = {}) { }: TitleBarProps = {}) {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
@@ -32,7 +36,7 @@ function TitleBar({
return ( return (
<div className="title-bar"> <div className="title-bar">
<div className="title-brand"> <div className="title-brand">
<img src="./logo.png" alt="WeFlow" className="title-logo" /> {showLogo && <img src="./logo.png" alt="WeFlow" className="title-logo" />}
<span className="titles">{title || 'WeFlow'}</span> <span className="titles">{title || 'WeFlow'}</span>
{onToggleSidebar ? ( {onToggleSidebar ? (
<button <button
@@ -46,6 +50,7 @@ function TitleBar({
</button> </button>
) : null} ) : null}
</div> </div>
{customControls}
{showWindowControls ? ( {showWindowControls ? (
<div className="title-window-controls"> <div className="title-window-controls">
<button <button

View File

@@ -14,7 +14,7 @@
.update-dialog { .update-dialog {
width: 680px; width: 680px;
background: #f5f5f5; background: var(--bg-secondary, #f5f5f5);
border-radius: 24px; border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
overflow: hidden; overflow: hidden;
@@ -25,7 +25,7 @@
/* Top Section (White/Gradient) */ /* Top Section (White/Gradient) */
.dialog-header { .dialog-header {
background: #ffffff; background: var(--bg-primary, #ffffff);
padding: 40px 20px 30px; padding: 40px 20px 30px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -41,14 +41,14 @@
left: -50px; left: -50px;
width: 200px; width: 200px;
height: 200px; height: 200px;
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%); background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%);
opacity: 0.8; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
.version-tag { .version-tag {
background: #f0eee9; background: var(--bg-tertiary, #f0eee9);
color: #8c7b6e; color: var(--text-tertiary, #8c7b6e);
padding: 4px 16px; padding: 4px 16px;
border-radius: 12px; border-radius: 12px;
font-size: 13px; font-size: 13px;
@@ -60,21 +60,21 @@
h2 { h2 {
font-size: 32px; font-size: 32px;
font-weight: 800; font-weight: 800;
color: #333333; color: var(--text-primary, #333333);
margin: 0 0 12px; margin: 0 0 12px;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.subtitle { .subtitle {
font-size: 15px; font-size: 15px;
color: #999999; color: var(--text-secondary, #999999);
font-weight: 400; font-weight: 400;
} }
} }
/* Content Section (Light Gray) */ /* Content Section (Light Gray) */
.dialog-content { .dialog-content {
background: #f2f2f2; background: var(--bg-tertiary, #f2f2f2);
padding: 24px 40px 40px; padding: 24px 40px 40px;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -87,7 +87,7 @@
margin-bottom: 30px; margin-bottom: 30px;
.icon-box { .icon-box {
background: #fbfbfb; // Beige-ish white background: var(--bg-primary, #fbfbfb);
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 16px; border-radius: 16px;
@@ -96,7 +96,7 @@
justify-content: center; justify-content: center;
margin-right: 20px; margin-right: 20px;
flex-shrink: 0; flex-shrink: 0;
color: #8c7b6e; color: var(--text-tertiary, #8c7b6e);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
svg { svg {
@@ -107,27 +107,38 @@
.text-box { .text-box {
flex: 1; flex: 1;
h3 { h1, h2, h3, h4, h5, h6 {
font-size: 18px; color: var(--text-primary, #333333);
font-weight: 700; font-weight: 700;
color: #333333; margin: 16px 0 8px;
margin: 0 0 8px;
&:first-child {
margin-top: 0;
}
}
h2 {
font-size: 16px;
}
h3 {
font-size: 15px;
} }
p { p {
font-size: 14px; font-size: 14px;
color: #666666; color: var(--text-secondary, #666666);
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 4px 0;
} }
ul { ul {
margin: 8px 0 0 18px; margin: 4px 0 0 18px;
padding: 0; padding: 0;
li { li {
font-size: 14px; font-size: 14px;
color: #666666; color: var(--text-secondary, #666666);
line-height: 1.6; line-height: 1.6;
} }
} }
@@ -142,19 +153,19 @@
justify-content: space-between; justify-content: space-between;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: 12px;
color: #888; color: var(--text-secondary, #888);
font-weight: 500; font-weight: 500;
} }
.progress-bar-bg { .progress-bar-bg {
height: 6px; height: 6px;
background: #e0e0e0; background: var(--border-color, #e0e0e0);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
.progress-bar-fill { .progress-bar-fill {
height: 100%; height: 100%;
background: #000000; background: var(--text-primary, #000000);
border-radius: 3px; border-radius: 3px;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -164,7 +175,7 @@
text-align: center; text-align: center;
margin-top: 12px; margin-top: 12px;
font-size: 13px; font-size: 13px;
color: #666; color: var(--text-secondary, #666);
} }
} }
@@ -175,8 +186,8 @@
.btn-ignore { .btn-ignore {
background: transparent; background: transparent;
color: #666666; color: var(--text-secondary, #666666);
border: 1px solid #d0d0d0; border: 1px solid var(--border-color, #d0d0d0);
padding: 16px 32px; padding: 16px 32px;
border-radius: 20px; border-radius: 20px;
font-size: 16px; font-size: 16px;
@@ -185,9 +196,9 @@
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
background: #f5f5f5; background: var(--bg-hover, #f5f5f5);
border-color: #999999; border-color: var(--text-secondary, #999999);
color: #333333; color: var(--text-primary, #333333);
} }
&:active { &:active {
@@ -196,11 +207,11 @@
} }
.btn-update { .btn-update {
background: #000000; background: var(--text-primary, #000000);
color: #ffffff; color: var(--bg-primary, #ffffff);
border: none; border: none;
padding: 16px 48px; padding: 16px 48px;
border-radius: 20px; // Pill shape border-radius: 20px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -231,7 +242,7 @@
right: 16px; right: 16px;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
border: none; border: none;
color: #999; color: var(--text-secondary, #999);
cursor: pointer; cursor: pointer;
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -244,7 +255,7 @@
&:hover { &:hover {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
color: #333; color: var(--text-primary, #333);
transform: rotate(90deg); transform: rotate(90deg);
} }
} }

View File

@@ -89,7 +89,6 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
<Quote size={20} /> <Quote size={20} />
</div> </div>
<div className="text-box"> <div className="text-box">
<h3></h3>
{updateInfo.releaseNotes ? ( {updateInfo.releaseNotes ? (
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} /> <div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
) : ( ) : (

View File

@@ -4442,18 +4442,23 @@
// 折叠群入口样式 // 折叠群入口样式
.session-item.fold-entry { .session-item.fold-entry {
background: var(--card-inner-bg, rgba(0,0,0,0.03)); cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: var(--hover-bg, rgba(0,0,0,0.05));
}
.fold-entry-avatar { .fold-entry-avatar {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 8px; border-radius: 8px;
background: var(--primary-color, #07c160); background: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: #fff; color: #fa9d3b;
} }
.session-name { .session-name {

View File

@@ -356,18 +356,19 @@ const SessionItem = React.memo(function SessionItem({
if (isFoldEntry) { if (isFoldEntry) {
return ( return (
<div <div
className={`session-item fold-entry`} className={`session-item fold-entry ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)} onClick={() => onSelect(session)}
> >
<div className="fold-entry-avatar"> <div className="fold-entry-avatar">
<FolderClosed size={22} /> <MessageSquare size={22} />
</div> </div>
<div className="session-info"> <div className="session-info">
<div className="session-top"> <div className="session-top">
<span className="session-name"></span> <span className="session-name"></span>
<span className="session-time">{timeText}</span>
</div> </div>
<div className="session-bottom"> <div className="session-bottom">
<span className="session-summary">{session.summary || ''}</span> <span className="session-summary">{session.summary || '暂无消息'}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2966,10 +2967,51 @@ function ChatPage(props: ChatPageProps) {
setFilteredSessions([]) setFilteredSessions([])
return return
} }
const visible = sessions.filter(s => {
// 检查是否有折叠的群聊
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
const hasFoldedGroups = foldedGroups.length > 0
let visible = sessions.filter(s => {
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
return true return true
}) })
// 如果有折叠的群聊,但列表中没有入口,则插入入口
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
// 找到最新的折叠消息
const latestFolded = foldedGroups.reduce((latest, current) => {
const latestTime = latest.sortTimestamp || latest.lastTimestamp
const currentTime = current.sortTimestamp || current.lastTimestamp
return currentTime > latestTime ? current : latest
})
const foldEntry: ChatSession = {
username: 'placeholder_foldgroup',
displayName: '折叠的聊天',
summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`,
type: 0,
sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp,
lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp,
lastMsgType: 0,
unreadCount: foldedGroups.reduce((sum, s) => sum + (s.unreadCount || 0), 0),
isMuted: false,
isFolded: false
}
// 按时间戳插入到正确位置
const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp
const insertIndex = visible.findIndex(s => {
const sTime = s.sortTimestamp || s.lastTimestamp
return sTime < foldTime
})
if (insertIndex === -1) {
visible.push(foldEntry)
} else {
visible.splice(insertIndex, 0, foldEntry)
}
}
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
setFilteredSessions(visible) setFilteredSessions(visible)
return return

View File

@@ -7,76 +7,6 @@
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
.title-bar {
height: 40px;
min-height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding-right: 140px; // 为原生窗口控件留出空间
.window-drag-area {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
.title-bar-controls {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
margin-right: 16px;
button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn {
&.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
}
.scale-text {
min-width: 50px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.divider {
width: 1px;
height: 14px;
background: var(--border-color);
margin: 0 4px;
}
}
}
.image-viewport { .image-viewport {
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon' import { LivePhotoIcon } from '../components/LivePhotoIcon'
import TitleBar from '../components/TitleBar'
import './ImageWindow.scss' import './ImageWindow.scss'
export default function ImageWindow() { export default function ImageWindow() {
@@ -207,31 +208,35 @@ export default function ImageWindow() {
return ( return (
<div className="image-window-container"> <div className="image-window-container">
<div className="title-bar"> <TitleBar
<div className="window-drag-area"></div> title="图片查看"
<div className="title-bar-controls"> showWindowControls={true}
{hasLiveVideo && ( showLogo={false}
<> customControls={
<button <div className="image-controls">
onClick={handlePlayLiveVideo} {hasLiveVideo && (
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'} <>
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`} <button
disabled={isPlayingLive} onClick={handlePlayLiveVideo}
> title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
<LivePhotoIcon size={16} /> className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span> disabled={isPlayingLive}
</button> >
<div className="divider"></div> <LivePhotoIcon size={16} />
</> <span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
)} </button>
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button> <div className="divider"></div>
<span className="scale-text">{Math.round(displayScale * 100)}%</span> </>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button> )}
<div className="divider"></div> <button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button> <span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button> <button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
</div> <div className="divider"></div>
</div> <button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
}
/>
<div <div
className="image-viewport" className="image-viewport"

View File

@@ -897,6 +897,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
} }
const handleClearLog = async () => {
const confirmed = window.confirm('确定清空 wcdb.log 吗?')
if (!confirmed) return
try {
const result = await window.electronAPI.log.clear()
if (!result.success) {
showMessage(result.error || '清空日志失败', false)
return
}
showMessage('日志已清空', true)
} catch (e: any) {
showMessage(`清空日志失败: ${e}`, false)
}
}
const handleClearAnalyticsCache = async () => { const handleClearAnalyticsCache = async () => {
if (isClearingCache) return if (isClearingCache) return
setIsClearingAnalyticsCache(true) setIsClearingAnalyticsCache(true)
@@ -1379,15 +1394,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid })) scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
}} }}
/> />
<div className="form-hint" style={{ color: '#f59e0b', margin: '6px 0' }}>
使
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)"> <button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'} <Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐'}
</button> </button>
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高"> <button className="btn btn-secondary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'} {isFetchingImageKey ? '扫描中...' : '内存扫描'}
</button> </button>
</div> </div>
{isFetchingImageKey ? ( {isFetchingImageKey ? (
@@ -1399,7 +1411,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) : ( ) : (
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div> imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
)} )}
<span className="form-hint"> 2-3 </span> <span className="form-hint">使 2-3 </span>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -1430,6 +1442,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<button className="btn btn-secondary" onClick={handleCopyLog}> <button className="btn btn-secondary" onClick={handleCopyLog}>
<Copy size={16} /> <Copy size={16} />
</button> </button>
<button className="btn btn-secondary" onClick={handleClearLog}>
<Trash2 size={16} />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -2046,7 +2061,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} /> <RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'} {isCheckingUpdate ? '检查中...' : '检查更新'}
</button> </button>
</div> </div>
)} )}
</div> </div>

View File

@@ -780,9 +780,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'image' && ( {currentStep.id === 'image' && (
<div className="form-group"> <div className="form-group">
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
使
</div>
<div className="grid-2"> <div className="grid-2">
<div> <div>
<label className="field-label"> XOR </label> <label className="field-label"> XOR </label>
@@ -795,11 +792,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</div> </div>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)"> <button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'} {isFetchingImageKey ? '获取中...' : '缓存计算(推荐'}
</button> </button>
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行"> <button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'} {isFetchingImageKey ? '扫描中...' : '内存扫描'}
</button> </button>
</div> </div>
@@ -813,7 +810,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div> imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)} )}
<div className="field-hint" style={{ marginTop: '8px' }}> 2-3 </div> <div className="field-hint" style={{ marginTop: '8px' }}>使 2-3 </div>
</div> </div>
)} )}
</div> </div>

View File

@@ -69,6 +69,7 @@ export interface ElectronAPI {
log: { log: {
getPath: () => Promise<string> getPath: () => Promise<string>
read: () => Promise<{ success: boolean; content?: string; error?: string }> read: () => Promise<{ success: boolean; content?: string; error?: string }>
clear: () => Promise<{ success: boolean; error?: string }>
debug: (data: any) => void debug: (data: any) => void
} }
diagnostics: { diagnostics: {