From 25b72c3d761bf2c4744c99e9c4071cc7a71f39f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <84499436+Wxw-Gu@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:36:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal-wechat-capability-service.ts | 2 +- .../services/personal-wechat-send-service.ts | 101 +++++++++++++++++- .../services/scheduled-report-api-service.ts | 5 +- src/main/services/scheduled-report-service.ts | 2 +- src/renderer/src/App.tsx | 14 +-- .../src/components/chat/ChatHeader.tsx | 4 +- .../src/components/reports/ReportViewer.tsx | 2 +- .../reports/ScheduledReportsWorkspace.tsx | 6 +- .../settings/pages/PersonalWechatSendPage.tsx | 2 +- tests/component/report-features.test.tsx | 2 +- .../scheduled-reports-workspace.test.tsx | 4 +- tests/e2e/scheduled-reports.spec.ts | 5 +- tests/e2e/support/electron-main.cjs | 31 +++--- .../unit/personal-wechat-send-service.test.ts | 47 +++++++- .../unit/scheduled-report-api-service.test.ts | 19 +++- 15 files changed, 205 insertions(+), 41 deletions(-) diff --git a/src/main/services/personal-wechat-capability-service.ts b/src/main/services/personal-wechat-capability-service.ts index 2e58d92..9e3be55 100644 --- a/src/main/services/personal-wechat-capability-service.ts +++ b/src/main/services/personal-wechat-capability-service.ts @@ -36,7 +36,7 @@ export class PersonalWechatCapabilityService { senderStatus, message: status === 'unsupported' - ? '微信消息发送目前仅支持 macOS' + ? '微信消息发送目前仅支持 macOS 和 Windows' : senderStatus.message || (status === 'needs_binding' || status === 'unconfigured' ? '请先绑定个人微信' diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 1b18097..5cb325d 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -1,10 +1,19 @@ import { app } from 'electron' import { execFile, spawn, type ChildProcess } from 'child_process' import { createHash, randomBytes, randomUUID } from 'crypto' -import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + unlinkSync, + writeFileSync +} from 'fs' import { createConnection } from 'net' import { homedir, tmpdir } from 'os' -import { delimiter, dirname, join, sep } from 'path' +import { delimiter, dirname, extname, join, sep } from 'path' import ffmpegStaticPath from 'ffmpeg-static' import { promisify } from 'util' import type { @@ -42,6 +51,12 @@ const WECHAT_FILES_ROOT = join( 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' ) +const ASCII_PATH_PATTERN = /^[\x20-\x7e]+$/ + +function isAsciiPath(value: string): boolean { + return ASCII_PATH_PATTERN.test(value) +} + export function normalizeWindowsWechatPort(value: unknown): string | null { const text = String(value ?? '').trim() if (!/^\d{1,5}$/.test(text)) return null @@ -591,6 +606,59 @@ async function prepareWindowsVoiceFile(filePath: string): Promise<{ } } +function windowsImageTempRoot(): string { + const candidates = [ + process.platform === 'win32' && process.env['PUBLIC'] + ? join(process.env['PUBLIC'], 'TraceMemo', 'temp') + : undefined, + process.platform === 'win32' && process.env['ProgramData'] + ? join(process.env['ProgramData'], 'TraceMemo', 'temp') + : undefined, + process.platform === 'win32' ? join('C:', 'Users', 'Public', 'TraceMemo', 'temp') : undefined, + tmpdir(), + process.env['TEMP'], + process.env['TMP'] + ] + for (const candidate of candidates) { + const normalized = String(candidate || '').trim() + if (!normalized || !isAsciiPath(normalized)) continue + try { + mkdirSync(normalized, { recursive: true }) + return normalized + } catch { + // + } + } + throw new Error('无法创建 Windows 图片临时目录') +} + +export function prepareWindowsImageFile(filePath: string): { + filePath: string + temporary: boolean +} { + const normalized = String(filePath || '').trim() + if (isAsciiPath(normalized)) return { filePath: normalized, temporary: false } + + const extension = extname(normalized).toLowerCase() + const safeExtension = /^\.[a-z0-9]{1,8}$/.test(extension) ? extension : '.png' + const temporaryPath = join( + windowsImageTempRoot(), + `tm-img-${randomBytes(8).toString('hex')}${safeExtension}` + ) + if (!isAsciiPath(temporaryPath)) throw new Error('Windows 图片临时路径必须只包含 ASCII 字符') + try { + copyFileSync(normalized, temporaryPath) + } catch (error) { + try { + unlinkSync(temporaryPath) + } catch { + // A partially copied file is best-effort cleanup only. + } + throw error + } + return { filePath: temporaryPath, temporary: true } +} + async function readWechatVersion(): Promise { const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ '-c', @@ -736,6 +804,17 @@ async function requestWithTimeout( type WindowsHookResponse = Record +export class WindowsHookHttpError extends Error { + constructor( + readonly status: number, + readonly responseBody: string + ) { + const detail = responseBody.trim() + super(detail ? `HTTP ${status}: ${detail.slice(0, 1_000)}` : `HTTP ${status}`) + this.name = 'WindowsHookHttpError' + } +} + export function parseWindowsHookResponse( responseText: string, requireSuccessRet = false @@ -843,7 +922,7 @@ async function requestWindowsHook( if (method !== 'GET') init.body = JSON.stringify(body) const response = await requestWithTimeout(`http://${host}${endpoint}`, init, timeoutMs) const responseText = await response.text() - if (!response.ok) throw new Error(responseText || `HTTP ${response.status}`) + if (!response.ok) throw new WindowsHookHttpError(response.status, responseText) return parseWindowsHookResponse(responseText, method === 'POST') } @@ -1354,11 +1433,20 @@ export class PersonalWechatSendService { const status = await this.getWindowsStatus() if (!status.canSend) return { success: false, status, error: status.error || status.message } + let temporaryImagePath: string | undefined let temporaryVoicePath: string | undefined try { if (request.type === 'text') { const windowsRequest = buildWindowsWechatRequest(request) await requestWindowsHook(windowsRequest.endpoint, windowsRequest.body) + } else if (request.type === 'image') { + const preparedImage = prepareWindowsImageFile(request.filePath) + temporaryImagePath = preparedImage.temporary ? preparedImage.filePath : undefined + const windowsRequest = buildWindowsWechatRequest({ + ...request, + filePath: preparedImage.filePath + }) + await requestWindowsHook(windowsRequest.endpoint, windowsRequest.body, 60_000) } else if (request.type === 'voice') { const fromId = String(request.fromId || '').trim() if (!fromId) throw new Error('无法识别当前微信账号 wxid,无法发送语音') @@ -1391,6 +1479,13 @@ export class PersonalWechatSendService { error: `发送失败:${message}` } } finally { + if (temporaryImagePath) { + try { + unlinkSync(temporaryImagePath) + } catch { + // Temporary files are best-effort cleanup only. + } + } if (temporaryVoicePath) { try { unlinkSync(temporaryVoicePath) diff --git a/src/main/services/scheduled-report-api-service.ts b/src/main/services/scheduled-report-api-service.ts index 2ff7cef..35da1c1 100644 --- a/src/main/services/scheduled-report-api-service.ts +++ b/src/main/services/scheduled-report-api-service.ts @@ -301,14 +301,15 @@ export class ScheduledReportApiService { private applyPlatformCapability( capability: PersonalWechatSendCapability ): PersonalWechatSendCapability { - if ((this.deps.platform || process.platform) === 'darwin') return capability + const platform = this.deps.platform || process.platform + if (platform === 'darwin' || platform === 'win32') return capability return { ...capability, supported: false, ready: false, status: 'unsupported', capabilities: { text: false, image: false, voice: false }, - message: '微信消息发送目前仅支持 macOS' + message: '微信消息发送目前仅支持 macOS 和 Windows' } } diff --git a/src/main/services/scheduled-report-service.ts b/src/main/services/scheduled-report-service.ts index 1806326..56f2425 100644 --- a/src/main/services/scheduled-report-service.ts +++ b/src/main/services/scheduled-report-service.ts @@ -344,7 +344,7 @@ export class ScheduledReportService { } private creationError(capability: PersonalWechatSendCapability): string { - if (capability.status === 'unsupported') return '微信消息发送目前仅支持 macOS' + if (capability.status === 'unsupported') return '微信消息发送目前仅支持 macOS 和 Windows' if (capability.status === 'needs_binding' || capability.status === 'unconfigured') return '请先绑定个人微信' if (capability.status === 'needs_verification') return '请先完成微信消息能力检测' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bbd6f78..0b42a0d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,7 +39,7 @@ import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/me import { enrichQuotedMessages } from './utils/quoted-messages' import type { SelectableReportTemplateId } from '../../shared/report-templates' import { switchGeneratedReportTemplate } from './utils/report-template-switch' -import { runtimePlatform } from './utils/runtime-environment' +import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment' import { useToast } from './components/ui' import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace' @@ -1783,24 +1783,24 @@ function App(): React.ReactElement { type="button" role="tab" aria-selected={reportSection === 'scheduled'} - aria-disabled={runtimePlatform !== 'darwin'} - className={`${reportSection === 'scheduled' ? 'active' : ''} ${runtimePlatform !== 'darwin' ? 'unsupported' : ''}`} + aria-disabled={!supportsPersonalWechatSend} + className={`${reportSection === 'scheduled' ? 'active' : ''} ${!supportsPersonalWechatSend ? 'unsupported' : ''}`} onClick={() => { - if (runtimePlatform !== 'darwin') { - toast({ description: '定时日报目前仅支持 macOS。', duration: 3200 }) + if (!supportsPersonalWechatSend) { + toast({ description: '定时日报目前仅支持 macOS 和 Windows。', duration: 3200 }) return } setReportSection('scheduled') }} > - 定时日报{runtimePlatform !== 'darwin' && 仅 macOS} + 定时日报{!supportsPersonalWechatSend && 仅 macOS / Windows}
{reportSection === 'scheduled' ? ( diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx index 3ece724..70dba60 100644 --- a/src/renderer/src/components/chat/ChatHeader.tsx +++ b/src/renderer/src/components/chat/ChatHeader.tsx @@ -115,7 +115,7 @@ export function ChatHeader({ ) : ( - +