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,
webSecurity: false // 允许加载本地文件
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
frame: false,
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
setupCustomTitleBarWindow(win)
win.once('ready-to-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 }) => {
return exportCardDiagnosticsService.snapshot(options?.limit)
})

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ export class KeyServiceMac {
private machVmRegion: any = null
private machVmReadOverwrite: any = null
private machPortDeallocate: any = null
private _needsElevation = false
private getHelperPath(): string {
const isPackaged = app.isPackaged
@@ -49,6 +50,26 @@ export class KeyServiceMac {
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 {
const isPackaged = app.isPackaged
const candidates: string[] = []
@@ -258,7 +279,7 @@ export class KeyServiceMac {
stdout += data
stdoutBuf += data
const parts = stdoutBuf.split(/\r?\n/)
stdoutBuf = parts.pop() || ''
stdoutBuf = parts.pop()!
})
child.stderr.on('data', (chunk: Buffer | string) => {
@@ -266,7 +287,7 @@ export class KeyServiceMac {
stderr += data
stderrBuf += data
const parts = stderrBuf.split(/\r?\n/)
stderrBuf = parts.pop() || ''
stderrBuf = parts.pop()!
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]), {
timeout: waitMs + 20_000
})
stdout = result.stdout || ''
stdout = result.stdout
} catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
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]
if (!last) throw new Error('elevated helper returned empty output')
@@ -614,6 +635,32 @@ export class KeyServiceMac {
ciphertext: Buffer,
onProgress?: (message: string) => void
): 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
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> {
const { execSync } = await import('child_process')
try {

View File

@@ -173,7 +173,6 @@ export class WcdbCore {
}
} catch {}
}
this.connectMonitorPipe(pipePath)
return true
} catch (e) {
@@ -190,13 +189,18 @@ export class WcdbCore {
setTimeout(() => {
if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
})
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
const rawChunk = data.toString('utf8')
// macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包
const normalizedChunk = rawChunk
.replace(/\u0000/g, '\n')
.replace(/}\s*{/g, '}\n{')
buffer += normalizedChunk
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
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('close', () => {

View File

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

View File

@@ -20,15 +20,17 @@ if (parentPort) {
result = { success: true }
break
case 'setMonitor':
core.setMonitor((type, json) => {
{
const monitorOk = core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
result = { success: monitorOk }
break
}
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
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 () => {
removeListener()
}
} else {
}
return () => { }
}, [])

View File

@@ -36,6 +36,8 @@
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.title-sidebar-toggle {
@@ -90,3 +92,55 @@
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
onToggleSidebar?: () => void
showWindowControls?: boolean
customControls?: React.ReactNode
showLogo?: boolean
}
function TitleBar({
title,
sidebarCollapsed = false,
onToggleSidebar,
showWindowControls = true
showWindowControls = true,
customControls,
showLogo = true
}: TitleBarProps = {}) {
const [isMaximized, setIsMaximized] = useState(false)
@@ -32,7 +36,7 @@ function TitleBar({
return (
<div className="title-bar">
<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>
{onToggleSidebar ? (
<button
@@ -46,6 +50,7 @@ function TitleBar({
</button>
) : null}
</div>
{customControls}
{showWindowControls ? (
<div className="title-window-controls">
<button

View File

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

View File

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

View File

@@ -4442,18 +4442,23 @@
// 折叠群入口样式
.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 {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--primary-color, #07c160);
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
color: #fa9d3b;
}
.session-name {

View File

@@ -356,18 +356,19 @@ const SessionItem = React.memo(function SessionItem({
if (isFoldEntry) {
return (
<div
className={`session-item fold-entry`}
className={`session-item fold-entry ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)}
>
<div className="fold-entry-avatar">
<FolderClosed size={22} />
<MessageSquare size={22} />
</div>
<div className="session-info">
<div className="session-top">
<span className="session-name"></span>
<span className="session-name"></span>
<span className="session-time">{timeText}</span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || ''}</span>
<span className="session-summary">{session.summary || '暂无消息'}</span>
</div>
</div>
</div>
@@ -2966,10 +2967,51 @@ function ChatPage(props: ChatPageProps) {
setFilteredSessions([])
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
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()) {
setFilteredSessions(visible)
return

View File

@@ -7,76 +7,6 @@
overflow: hidden;
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 {
flex: 1;
display: flex;

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import TitleBar from '../components/TitleBar'
import './ImageWindow.scss'
export default function ImageWindow() {
@@ -207,31 +208,35 @@ export default function ImageWindow() {
return (
<div className="image-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
</div>
<TitleBar
title="图片查看"
showWindowControls={true}
showLogo={false}
customControls={
<div className="image-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
}
/>
<div
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 () => {
if (isClearingCache) return
setIsClearingAnalyticsCache(true)
@@ -1379,15 +1394,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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' }}>
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
<button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐'}
</button>
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高">
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
<button className="btn btn-secondary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
</button>
</div>
{isFetchingImageKey ? (
@@ -1399,7 +1411,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) : (
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 className="form-group">
@@ -1430,6 +1442,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<button className="btn btn-secondary" onClick={handleCopyLog}>
<Copy size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearLog}>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
@@ -2046,7 +2061,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
</div>
)}
</div>

View File

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

View File

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