mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 定时日报支持Windows
This commit is contained in:
@@ -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 '请先完成微信消息能力检测'
|
||||||
|
|||||||
@@ -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 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
||||||
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user