feat: 定时日报支持Windows

This commit is contained in:
电摇小子
2026-09-01 11:36:15 +08:00
parent f65dc4ac32
commit 25b72c3d76
15 changed files with 205 additions and 41 deletions
@@ -36,7 +36,7 @@ export class PersonalWechatCapabilityService {
senderStatus, senderStatus,
message: message:
status === 'unsupported' status === 'unsupported'
? '微信消息发送目前仅支持 macOS' ? '微信消息发送目前仅支持 macOS 和 Windows'
: senderStatus.message || : senderStatus.message ||
(status === 'needs_binding' || status === 'unconfigured' (status === 'needs_binding' || status === 'unconfigured'
? '请先绑定个人微信' ? '请先绑定个人微信'
@@ -1,10 +1,19 @@
import { app } from 'electron' import { app } from 'electron'
import { execFile, spawn, type ChildProcess } from 'child_process' import { execFile, spawn, type ChildProcess } from 'child_process'
import { createHash, randomBytes, randomUUID } from 'crypto' 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 { createConnection } from 'net'
import { homedir, tmpdir } from 'os' 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 ffmpegStaticPath from 'ffmpeg-static'
import { promisify } from 'util' import { promisify } from 'util'
import type { import type {
@@ -42,6 +51,12 @@ const WECHAT_FILES_ROOT = join(
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' '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 { export function normalizeWindowsWechatPort(value: unknown): string | null {
const text = String(value ?? '').trim() const text = String(value ?? '').trim()
if (!/^\d{1,5}$/.test(text)) return null 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<string> { async function readWechatVersion(): Promise<string> {
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
'-c', '-c',
@@ -736,6 +804,17 @@ async function requestWithTimeout(
type WindowsHookResponse = Record<string, unknown> type WindowsHookResponse = Record<string, unknown>
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( export function parseWindowsHookResponse(
responseText: string, responseText: string,
requireSuccessRet = false requireSuccessRet = false
@@ -843,7 +922,7 @@ async function requestWindowsHook(
if (method !== 'GET') init.body = JSON.stringify(body) if (method !== 'GET') init.body = JSON.stringify(body)
const response = await requestWithTimeout(`http://${host}${endpoint}`, init, timeoutMs) const response = await requestWithTimeout(`http://${host}${endpoint}`, init, timeoutMs)
const responseText = await response.text() 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') return parseWindowsHookResponse(responseText, method === 'POST')
} }
@@ -1354,11 +1433,20 @@ export class PersonalWechatSendService {
const status = await this.getWindowsStatus() const status = await this.getWindowsStatus()
if (!status.canSend) return { success: false, status, error: status.error || status.message } if (!status.canSend) return { success: false, status, error: status.error || status.message }
let temporaryImagePath: string | undefined
let temporaryVoicePath: string | undefined let temporaryVoicePath: string | undefined
try { try {
if (request.type === 'text') { if (request.type === 'text') {
const windowsRequest = buildWindowsWechatRequest(request) const windowsRequest = buildWindowsWechatRequest(request)
await requestWindowsHook(windowsRequest.endpoint, windowsRequest.body) 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') { } else if (request.type === 'voice') {
const fromId = String(request.fromId || '').trim() const fromId = String(request.fromId || '').trim()
if (!fromId) throw new Error('无法识别当前微信账号 wxid,无法发送语音') if (!fromId) throw new Error('无法识别当前微信账号 wxid,无法发送语音')
@@ -1391,6 +1479,13 @@ export class PersonalWechatSendService {
error: `发送失败:${message}` error: `发送失败:${message}`
} }
} finally { } finally {
if (temporaryImagePath) {
try {
unlinkSync(temporaryImagePath)
} catch {
// Temporary files are best-effort cleanup only.
}
}
if (temporaryVoicePath) { if (temporaryVoicePath) {
try { try {
unlinkSync(temporaryVoicePath) unlinkSync(temporaryVoicePath)
@@ -301,14 +301,15 @@ export class ScheduledReportApiService {
private applyPlatformCapability( private applyPlatformCapability(
capability: PersonalWechatSendCapability capability: PersonalWechatSendCapability
): 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 { return {
...capability, ...capability,
supported: false, supported: false,
ready: false, ready: false,
status: 'unsupported', status: 'unsupported',
capabilities: { text: false, image: false, voice: false }, capabilities: { text: false, image: false, voice: false },
message: '微信消息发送目前仅支持 macOS' message: '微信消息发送目前仅支持 macOS 和 Windows'
} }
} }
@@ -344,7 +344,7 @@ export class ScheduledReportService {
} }
private creationError(capability: PersonalWechatSendCapability): string { 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') if (capability.status === 'needs_binding' || capability.status === 'unconfigured')
return '请先绑定个人微信' return '请先绑定个人微信'
if (capability.status === 'needs_verification') return '请先完成微信消息能力检测' if (capability.status === 'needs_verification') return '请先完成微信消息能力检测'
+7 -7
View File
@@ -39,7 +39,7 @@ import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/me
import { enrichQuotedMessages } from './utils/quoted-messages' import { enrichQuotedMessages } from './utils/quoted-messages'
import type { SelectableReportTemplateId } from '../../shared/report-templates' import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch' 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 { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace' import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
@@ -1783,24 +1783,24 @@ function App(): React.ReactElement {
type="button" type="button"
role="tab" role="tab"
aria-selected={reportSection === 'scheduled'} aria-selected={reportSection === 'scheduled'}
aria-disabled={runtimePlatform !== 'darwin'} aria-disabled={!supportsPersonalWechatSend}
className={`${reportSection === 'scheduled' ? 'active' : ''} ${runtimePlatform !== 'darwin' ? 'unsupported' : ''}`} className={`${reportSection === 'scheduled' ? 'active' : ''} ${!supportsPersonalWechatSend ? 'unsupported' : ''}`}
onClick={() => { onClick={() => {
if (runtimePlatform !== 'darwin') { if (!supportsPersonalWechatSend) {
toast({ description: '定时日报目前仅支持 macOS。', duration: 3200 }) toast({ description: '定时日报目前仅支持 macOS 和 Windows。', duration: 3200 })
return return
} }
setReportSection('scheduled') setReportSection('scheduled')
}} }}
> >
定时日报{runtimePlatform !== 'darwin' && <small>仅 macOS</small>} 定时日报{!supportsPersonalWechatSend && <small>仅 macOS / Windows</small>}
</button> </button>
</div> </div>
<div className="report-workspace-body"> <div className="report-workspace-body">
{reportSection === 'scheduled' ? ( {reportSection === 'scheduled' ? (
<ScheduledReportsWorkspace <ScheduledReportsWorkspace
contacts={contacts} contacts={contacts}
platformSupported={runtimePlatform === 'darwin'} platformSupported={supportsPersonalWechatSend}
onOpenWechatSettings={openWechatSendSettings} onOpenWechatSettings={openWechatSendSettings}
onOpenModelSettings={openModelSettings} onOpenModelSettings={openModelSettings}
onNotice={(message, variant) => onNotice={(message, variant) =>
@@ -115,7 +115,7 @@ export function ChatHeader({
) : ( ) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span tabIndex={0} aria-label="仅支持 macOS"> <span tabIndex={0} aria-label="仅支持 macOS 和 Windows">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -129,7 +129,7 @@ export function ChatHeader({
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>仅支持 macOS</TooltipContent> <TooltipContent>仅支持 macOS 和 Windows</TooltipContent>
</Tooltip> </Tooltip>
)} )}
<Button <Button
@@ -80,7 +80,7 @@ export function ReportViewer({
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report]) const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
const sendToGroupHint = !personalWechatSendSupported const sendToGroupHint = !personalWechatSendSupported
? '仅支持 macOS' ? '仅支持 macOS 和 Windows'
: !sendTarget : !sendTarget
? '未找到这份日报对应的群聊' ? '未找到这份日报对应的群聊'
: !report?.pngPath : !report?.pngPath
@@ -106,7 +106,7 @@ const capabilityCopy = (capability: PersonalWechatSendCapability | null): string
case 'ready': case 'ready':
return '✓ 微信发送能力已就绪' return '✓ 微信发送能力已就绪'
case 'unsupported': case 'unsupported':
return '微信消息发送目前仅支持 macOS' return '微信消息发送目前仅支持 macOS 和 Windows'
case 'needs_binding': case 'needs_binding':
case 'unconfigured': case 'unconfigured':
return '请先绑定个人微信' return '请先绑定个人微信'
@@ -127,7 +127,7 @@ const readableError = (error?: string): string => {
const value = String(error || '') const value = String(error || '')
if (value.includes('wechat_not_ready') || value.includes('仅支持 macOS')) { if (value.includes('wechat_not_ready') || value.includes('仅支持 macOS')) {
if (value.includes('unsupported') || value.includes('仅支持')) if (value.includes('unsupported') || value.includes('仅支持'))
return '微信消息发送目前仅支持 macOS' return '微信消息发送目前仅支持 macOS 和 Windows'
if (value.includes('needs_binding') || value.includes('unconfigured')) return '请先绑定个人微信' if (value.includes('needs_binding') || value.includes('unconfigured')) return '请先绑定个人微信'
if (value.includes('needs_verification')) return '请先完成微信消息能力检测' if (value.includes('needs_verification')) return '请先完成微信消息能力检测'
return '微信发送能力不可用,请检查微信连接。' return '微信发送能力不可用,请检查微信连接。'
@@ -576,7 +576,7 @@ export function ScheduledReportsWorkspace({
status: 'unsupported', status: 'unsupported',
capabilities: { text: false, image: false, voice: false }, capabilities: { text: false, image: false, voice: false },
senderStatus: {} as PersonalWechatSendCapability['senderStatus'], senderStatus: {} as PersonalWechatSendCapability['senderStatus'],
message: '微信消息发送目前仅支持 macOS' message: '定时日报目前仅支持 macOS 和 Windows'
}) })
setTasks([]) setTasks([])
setExecutions([]) setExecutions([])
@@ -367,7 +367,7 @@ export function PersonalWechatSendPage({
<strong className="mt-1 block text-base">{pageMessage}</strong> <strong className="mt-1 block text-base">{pageMessage}</strong>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{pageStatus === 'unsupported' && !isWindows {pageStatus === 'unsupported' && !isWindows
? '微信消息发送目前仅支持 macOS。' ? '微信消息发送目前仅支持 macOS 和 Windows。'
: isWindows : isWindows
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。' ? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'} : '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
+1 -1
View File
@@ -647,7 +647,7 @@ describe('daily report controls', () => {
const button = screen.getByRole('button', { name: '发送到当前群聊' }) const button = screen.getByRole('button', { name: '发送到当前群聊' })
expect(button).toBeDisabled() expect(button).toBeDisabled()
expect(button.parentElement).toHaveAttribute('title', '仅支持 macOS') expect(button.parentElement).toHaveAttribute('title', '仅支持 macOS 和 Windows')
}) })
it('switches templates from the top toolbar using the saved report snapshot', async () => { it('switches templates from the top toolbar using the saved report snapshot', async () => {
@@ -81,7 +81,9 @@ describe('ScheduledReportsWorkspace', () => {
onOpenWechatSettings={onOpenWechatSettings} onOpenWechatSettings={onOpenWechatSettings}
/> />
) )
await waitFor(() => expect(screen.getByText('微信消息发送目前仅支持 macOS')).toBeVisible()) await waitFor(() =>
expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible()
)
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeDisabled() expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeDisabled()
}) })
+4 -1
View File
@@ -2,7 +2,10 @@ import { expect, test } from '@playwright/test'
import { launchTestApp } from './support/electron' import { launchTestApp } from './support/electron'
test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => { test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => {
test.skip(process.platform !== 'darwin', 'The scheduled report send capability is macOS-only') test.skip(
process.platform !== 'darwin' && process.platform !== 'win32',
'The scheduled report send capability requires macOS or Windows'
)
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') }) const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
const pageErrors: Error[] = [] const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error)) fixture.page.on('pageerror', (error) => pageErrors.push(error))
+17 -14
View File
@@ -16,6 +16,7 @@ const imageData = `data:image/png;base64,${fs.readFileSync(path.join(root, 'reso
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
const configuredNow = Number(process.env.WXE_E2E_NOW_MS) const configuredNow = Number(process.env.WXE_E2E_NOW_MS)
const updateSimulation = process.env.WXE_E2E_UPDATE_SIMULATION === '1' const updateSimulation = process.env.WXE_E2E_UPDATE_SIMULATION === '1'
const personalWechatSupported = process.platform === 'darwin' || process.platform === 'win32'
const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1' const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1'
const fixtureNowMs = const fixtureNowMs =
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now() Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
@@ -378,8 +379,8 @@ handle('wechat-personal:getRuntimeStatus', () => ({
})) }))
handle('wechat-personal:getStatus', () => ({ handle('wechat-personal:getStatus', () => ({
state: 'online', state: 'online',
platform: 'darwin', platform: process.platform,
arch: 'arm64', arch: process.arch,
sipDisabled: true, sipDisabled: true,
wechatRunning: true, wechatRunning: true,
wechatPid: 4668, wechatPid: 4668,
@@ -404,16 +405,16 @@ handle('wechat-personal:getStatus', () => ({
message: '个人微信已绑定' message: '个人微信已绑定'
})) }))
handle('wechat-personal:getSendCapability', () => ({ handle('wechat-personal:getSendCapability', () => ({
supported: process.platform === 'darwin', supported: personalWechatSupported,
ready: process.platform === 'darwin', ready: personalWechatSupported,
status: process.platform === 'darwin' ? 'ready' : 'unsupported', status: personalWechatSupported ? 'ready' : 'unsupported',
capabilities: { capabilities: {
text: process.platform === 'darwin', text: personalWechatSupported,
image: process.platform === 'darwin', image: personalWechatSupported,
voice: process.platform === 'darwin' voice: personalWechatSupported
}, },
senderStatus: { senderStatus: {
state: process.platform === 'darwin' ? 'online' : 'unsupported_platform', state: personalWechatSupported ? 'online' : 'unsupported_platform',
platform: process.platform, platform: process.platform,
arch: process.arch, arch: process.arch,
sipDisabled: true, sipDisabled: true,
@@ -429,14 +430,16 @@ handle('wechat-personal:getSendCapability', () => ({
imageHookInstalled: true, imageHookInstalled: true,
imageHookReady: true, imageHookReady: true,
messageListenerReady: true, messageListenerReady: true,
canSend: process.platform === 'darwin', canSend: personalWechatSupported,
canSendText: process.platform === 'darwin', canSendText: personalWechatSupported,
canSendImage: process.platform === 'darwin', canSendImage: personalWechatSupported,
canSendVoice: process.platform === 'darwin', canSendVoice: personalWechatSupported,
message: '个人微信已绑定' message: '个人微信已绑定'
}, },
message: message:
process.platform === 'darwin' ? '个人微信已准备好发送日报' : '微信消息发送目前仅支持 macOS' personalWechatSupported
? '个人微信已准备好发送日报'
: '微信消息发送目前仅支持 macOS 和 Windows'
})) }))
handle('scheduled-report:list', () => [...scheduledReportTasks]) handle('scheduled-report:list', () => [...scheduledReportTasks])
handle('scheduled-report:listExecutions', (taskId) => handle('scheduled-report:listExecutions', (taskId) =>
@@ -1,4 +1,12 @@
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs' import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
unlinkSync,
utimesSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join, sep } from 'path' import { join, sep } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -20,7 +28,9 @@ import {
normalizeWindowsWechatPort, normalizeWindowsWechatPort,
parseWindowsLoginStatus, parseWindowsLoginStatus,
parseWindowsHookResponse, parseWindowsHookResponse,
parsePersonalWechatHookLog parsePersonalWechatHookLog,
prepareWindowsImageFile,
WindowsHookHttpError
} from '../../src/main/services/personal-wechat-send-service' } from '../../src/main/services/personal-wechat-send-service'
const temporaryDirectories: string[] = [] const temporaryDirectories: string[] = []
@@ -158,6 +168,14 @@ describe('Windows request', () => {
expect(() => parseWindowsHookResponse('', true)).toThrow('空响应') expect(() => parseWindowsHookResponse('', true)).toThrow('空响应')
}) })
it('preserves the Windows Hook HTTP status and response body', () => {
const error = new WindowsHookHttpError(500, '{"ret":-9,"retmsg":"image failed"}')
expect(error.message).toBe('HTTP 500: {"ret":-9,"retmsg":"image failed"}')
expect(error.status).toBe(500)
expect(error.responseBody).toContain('image failed')
expect(new WindowsHookHttpError(500, '').message).toBe('HTTP 500')
})
it('allows Windows sending only for a strict logged-in status', () => { it('allows Windows sending only for a strict logged-in status', () => {
expect(parseWindowsLoginStatus({ status: true })).toBe(true) expect(parseWindowsLoginStatus({ status: true })).toBe(true)
expect(parseWindowsLoginStatus({ status: false })).toBe(false) expect(parseWindowsLoginStatus({ status: false })).toBe(false)
@@ -217,6 +235,31 @@ describe('Windows request', () => {
} }
}) })
}) })
it('copies adjacent non-ASCII image paths to ASCII temporary files', () => {
const root = temporaryDirectory()
const content = Buffer.from('fixture-image')
for (const fileName of ['测试图片_2026-08-31_经典.png', '测试图片_2026-08-31_经典版.png']) {
const source = join(root, fileName)
writeFileSync(source, content)
const prepared = prepareWindowsImageFile(source)
expect(prepared.temporary).toBe(true)
expect(prepared.filePath).not.toContain(fileName)
expect(prepared.filePath).toMatch(/^[\x20-\x7e]+$/)
expect(readFileSync(prepared.filePath)).toEqual(content)
unlinkSync(prepared.filePath)
}
})
it('keeps an ASCII image path unchanged', () => {
const root = temporaryDirectory()
const source = join(root, 'report.png')
writeFileSync(source, 'fixture-image')
expect(prepareWindowsImageFile(source)).toEqual({ filePath: source, temporary: false })
})
}) })
describe('personal WeChat runtime discovery', () => { describe('personal WeChat runtime discovery', () => {
@@ -136,7 +136,7 @@ describe('ScheduledReportApiService', () => {
} }
) )
it('enforces the macOS platform gate even if a provider reports ready', async () => { it('allows Windows when the image send capability is ready', async () => {
const { service } = makeApi() const { service } = makeApi()
const api = new ScheduledReportApiService({ const api = new ScheduledReportApiService({
service, service,
@@ -145,6 +145,23 @@ describe('ScheduledReportApiService', () => {
isDatabaseReady: () => true, isDatabaseReady: () => true,
platform: 'win32' platform: 'win32'
}) })
const created = await api.create({
group: '技术交流群',
schedule: { type: 'daily', time: '09:00' }
})
expect(created.group).toMatchObject({ talker: 'tech@chatroom' })
expect(service.createTask).toHaveBeenCalledOnce()
})
it('keeps unsupported platforms blocked even if a provider reports ready', async () => {
const { service } = makeApi()
const api = new ScheduledReportApiService({
service,
getCapability: async () => capability('ready'),
listContacts: () => contacts,
isDatabaseReady: () => true,
platform: 'linux'
})
await expect( await expect(
api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } }) api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 }) ).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })