mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 12:34:35 +08:00
feat: 完全支持 mac Intel 环境
抽离 Intel 专用包、connector 与跨架构语音库 修改 publish 脚本,发布先落草稿 修复重装依赖后 Electron 二进制缺失
This commit is contained in:
@@ -38,25 +38,24 @@ go version
|
||||
|
||||
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
|
||||
|
||||
项目的 [`.npmrc`](../../.npmrc) 已设置:
|
||||
**先看根因,别急着删 `node_modules` 重装。** `electron@43` 的 npm 包**不再声明 `postinstall`**(其 `package.json` 里 `scripts` 是空对象),下载改为「首次 `require('electron')` 时的懒加载」。因此:
|
||||
|
||||
```ini
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
```
|
||||
- `package.json` 里的 `pnpm.onlyBuiltDependencies: ["electron"]` 对它不起作用——上游没有脚本可执行,pnpm 无从下手;
|
||||
- `pnpm install` 跑完不会有任何二进制被下载,**只重装依赖解决不了这个问题**。
|
||||
|
||||
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
|
||||
项目已自动兜住这条路径:`scripts/ensure-electron-binary.cjs` 挂在 `postinstall` 与 `predev` 上,校验 `path.txt` 指向的可执行文件是否真的存在(只有 `path.txt` 而没有 `dist/` 同样算没装好),缺失时就地补下载。它读取 `.npmrc` 的 `electron_mirror`(当前为 `https://npmmirror.com/mirrors/electron/`),失败后再兜底重试一次该镜像。
|
||||
|
||||
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
|
||||
正常情况下你不需要做任何事。只有当自动步骤没有执行时(例如安装时带了 `--ignore-scripts`),才需要手动补一次:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-electron-binary.cjs
|
||||
```
|
||||
|
||||
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
|
||||
要换用别的镜像时,显式设置环境变量(优先于 `.npmrc`)。PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-electron-binary.cjs
|
||||
```
|
||||
|
||||
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
|
||||
|
||||
@@ -63,4 +63,6 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
releaseType: release
|
||||
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||
releaseType: draft
|
||||
|
||||
@@ -71,4 +71,6 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
releaseType: release
|
||||
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||
releaseType: draft
|
||||
|
||||
+11
-6
@@ -35,10 +35,12 @@
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
||||
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
|
||||
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "node -e \"require('electron')\"",
|
||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev",
|
||||
"poc:query-agent": "electron-vite build && node scripts/run-query-agent-poc.cjs",
|
||||
@@ -59,15 +61,18 @@
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
|
||||
"build:native-services": "npm run build:wechat-connector",
|
||||
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:mac": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64,x64 && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
|
||||
"release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||
"release:beta": "cross-env EP_PRE_RELEASE=true npm run release",
|
||||
"release:stable": "npm run release",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+116
-4
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { chmodSync, existsSync } = require('node:fs')
|
||||
const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const asar = require('@electron/asar')
|
||||
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||
|
||||
const REQUIRED_RUNTIME_PACKAGES = [
|
||||
'@electron-toolkit/preload',
|
||||
@@ -82,6 +83,43 @@ function normalizeBuilderArch(arch) {
|
||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreign-architecture binary only fails once the user touches the feature
|
||||
* that needs it, so verify the ones whose filename is shared across
|
||||
* architectures (ffmpeg-static keeps a single "ffmpeg" per platform) and fail
|
||||
* the build instead of shipping a broken bundle.
|
||||
*/
|
||||
function validateRuntimeBinaryArchitecture(filePath, platform, arch, label) {
|
||||
if (platform !== 'darwin' && platform !== 'win32') return
|
||||
if (arch === 'universal') return
|
||||
const architectures = readBinaryArchitectures(filePath)
|
||||
if (!architectures.length || architectures.includes(arch)) return
|
||||
throw new Error(
|
||||
`${label} is ${architectures.join('/')} but this bundle targets ${arch}: ${filePath}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Intel Mac key helper is an x86_64 executable that only the x64 (or
|
||||
* universal) macOS bundle can run. Every other target — Apple Silicon macOS,
|
||||
* Windows, Linux — would otherwise ship a ~34MB binary it can never execute,
|
||||
* so it is dropped from those bundles. x64/universal builds fail fast instead
|
||||
* of silently shipping an Intel Mac app that cannot read keys.
|
||||
*/
|
||||
function pruneIntelMacKeyTool(runtimeResources, platform, arch) {
|
||||
const keyToolDirectory = path.join(runtimeResources, 'resources', 'macos-key-tool')
|
||||
const usable = platform === 'darwin' && (arch === 'x64' || arch === 'universal')
|
||||
if (!usable) {
|
||||
rmSync(keyToolDirectory, { recursive: true, force: true })
|
||||
return null
|
||||
}
|
||||
const helperPath = path.join(keyToolDirectory, 'intel_mac_key_helper')
|
||||
if (!existsSync(helperPath)) {
|
||||
throw new Error(`Missing Intel Mac key helper in a ${arch} bundle: ${helperPath}`)
|
||||
}
|
||||
return helperPath
|
||||
}
|
||||
|
||||
function validateAsarRuntimeDependencies(runtimeResources) {
|
||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||
@@ -107,17 +145,87 @@ function validateReaderSkillRuntime(runtimeResources) {
|
||||
return skillPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Native runtime packages are published once per platform-arch pair, and pnpm
|
||||
* installs all of them, so every bundle ends up carrying the native libraries
|
||||
* of every platform (measured: ~129MB of speech models plus ~16MB of koffi).
|
||||
* The loaders pick their package from process.platform/arch, so the siblings
|
||||
* are dead weight — drop them.
|
||||
*/
|
||||
const NATIVE_RUNTIME_PACKAGES = [
|
||||
{
|
||||
modules: [],
|
||||
prefix: 'sherpa-onnx',
|
||||
platformName: (platform) => (platform === 'win32' ? 'win' : platform)
|
||||
},
|
||||
{
|
||||
modules: ['@koromix'],
|
||||
prefix: 'koffi',
|
||||
platformName: (platform) => platform
|
||||
}
|
||||
]
|
||||
|
||||
function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
|
||||
if (arch === 'universal') return []
|
||||
const unpackedRoot = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules')
|
||||
if (!existsSync(unpackedRoot)) return []
|
||||
const removed = []
|
||||
for (const runtime of NATIVE_RUNTIME_PACKAGES) {
|
||||
const modulesRoot = path.join(unpackedRoot, ...runtime.modules)
|
||||
if (!existsSync(modulesRoot)) continue
|
||||
const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}`
|
||||
const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
|
||||
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
|
||||
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
|
||||
removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled native directories under resources/connectors are named
|
||||
* "<platform>-<arch>". Cross-building both macOS architectures leaves both on
|
||||
* disk, but a bundle can only execute its own, so drop the foreign ones
|
||||
* instead of shipping every connector twice.
|
||||
*/
|
||||
function pruneForeignArchConnectors(runtimeResources, platform, arch) {
|
||||
if (arch === 'universal') return []
|
||||
const connectorsRoot = path.join(runtimeResources, 'resources', 'connectors')
|
||||
if (!existsSync(connectorsRoot)) return []
|
||||
const expected = `${platform}-${arch}`
|
||||
const removed = []
|
||||
for (const packageEntry of readdirSync(connectorsRoot, { withFileTypes: true })) {
|
||||
if (!packageEntry.isDirectory()) continue
|
||||
const packageRoot = path.join(connectorsRoot, packageEntry.name)
|
||||
for (const targetEntry of readdirSync(packageRoot, { withFileTypes: true })) {
|
||||
if (!targetEntry.isDirectory() || targetEntry.name === expected) continue
|
||||
if (!/^[a-z0-9]+-(arm64|x64|ia32)$/.test(targetEntry.name)) continue
|
||||
rmSync(path.join(packageRoot, targetEntry.name), { recursive: true, force: true })
|
||||
removed.push(`${packageEntry.name}/${targetEntry.name}`)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const runtimeResources = getRuntimeResources(context)
|
||||
const arch = normalizeBuilderArch(context.arch)
|
||||
validateAsarRuntimeDependencies(runtimeResources)
|
||||
validateReaderSkillRuntime(runtimeResources)
|
||||
validateSilkWasmRuntime(runtimeResources)
|
||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||
validateSherpaRuntime(
|
||||
runtimeResources,
|
||||
validateRuntimeBinaryArchitecture(
|
||||
ffmpegPath,
|
||||
context.electronPlatformName,
|
||||
normalizeBuilderArch(context.arch)
|
||||
arch,
|
||||
'Bundled ffmpeg'
|
||||
)
|
||||
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
||||
@@ -150,3 +258,7 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
|
||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
||||
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
||||
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
||||
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs')
|
||||
|
||||
const MACHO_MAGIC_32 = 0xfeedface
|
||||
const MACHO_MAGIC_64 = 0xfeedfacf
|
||||
const FAT_MAGIC = 0xcafebabe
|
||||
const FAT_MAGIC_64 = 0xcafebabf
|
||||
const PE_SIGNATURE = 0x00004550
|
||||
const PE_MACHINE_X64 = 0x8664
|
||||
const PE_MACHINE_ARM64 = 0xaa64
|
||||
|
||||
const CPU_TYPE_IA32 = 0x00000007
|
||||
const CPU_TYPE_X86_64 = 0x01000007
|
||||
const CPU_TYPE_ARM64 = 0x0100000c
|
||||
|
||||
/** Only the headers are needed; native binaries can be tens of megabytes. */
|
||||
const HEADER_BYTES = 64 * 1024
|
||||
|
||||
function readHeader(filePath) {
|
||||
const descriptor = fs.openSync(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEADER_BYTES)
|
||||
const bytesRead = fs.readSync(descriptor, buffer, 0, HEADER_BYTES, 0)
|
||||
return buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMachoCpuType(cpuType) {
|
||||
if (cpuType === CPU_TYPE_X86_64) return 'x64'
|
||||
if (cpuType === CPU_TYPE_ARM64) return 'arm64'
|
||||
if (cpuType === CPU_TYPE_IA32) return 'ia32'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every architecture contained in a Mach-O or PE binary, as
|
||||
* electron-builder arch names ("x64", "arm64"). Universal binaries report both.
|
||||
* Returns an empty array for anything that is not a native executable (scripts,
|
||||
* wasm), so callers can treat "unknown" separately from "wrong architecture".
|
||||
*/
|
||||
function readBinaryArchitectures(filePath) {
|
||||
let buffer
|
||||
try {
|
||||
buffer = readHeader(filePath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (buffer.length < 8) return []
|
||||
|
||||
const fatMagic = buffer.readUInt32BE(0)
|
||||
if (fatMagic === FAT_MAGIC || fatMagic === FAT_MAGIC_64) {
|
||||
const entrySize = fatMagic === FAT_MAGIC_64 ? 32 : 20
|
||||
const count = Math.min(buffer.readUInt32BE(4), 32)
|
||||
const architectures = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const entryOffset = 8 + index * entrySize
|
||||
if (entryOffset + 4 > buffer.length) break
|
||||
const name = normalizeMachoCpuType(buffer.readUInt32BE(entryOffset))
|
||||
if (name && !architectures.includes(name)) architectures.push(name)
|
||||
}
|
||||
return architectures
|
||||
}
|
||||
|
||||
const thinMagic = buffer.readUInt32LE(0)
|
||||
if (thinMagic === MACHO_MAGIC_32 || thinMagic === MACHO_MAGIC_64) {
|
||||
const name = normalizeMachoCpuType(buffer.readUInt32LE(4))
|
||||
return name ? [name] : []
|
||||
}
|
||||
|
||||
if (buffer.readUInt16LE(0) === 0x5a4d) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (peOffset + 6 > buffer.length || buffer.readUInt32LE(peOffset) !== PE_SIGNATURE) return []
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === PE_MACHINE_X64) return ['x64']
|
||||
if (machine === PE_MACHINE_ARM64) return ['arm64']
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
module.exports = { readBinaryArchitectures }
|
||||
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
/**
|
||||
* electron@43 的 npm 包不再声明 postinstall(它自己的 package.json 里 scripts 是空的),
|
||||
* 下载被改成「首次 require('electron') 时的懒加载」。后果是 `pnpm install` 之后
|
||||
* node_modules/electron 里只有一个空壳:dist/ 与 path.txt 都不在,而
|
||||
* package.json 里的 pnpm.onlyBuiltDependencies: ["electron"] 对着空气发号施令 ——
|
||||
* 上游没有脚本可跑,pnpm 自然什么也不做。新 clone 于是直到 `pnpm dev` 才炸。
|
||||
* 这里显式补上下载,并把它挂在 postinstall / predev 上。
|
||||
*/
|
||||
const MIRROR_FALLBACK = 'https://npmmirror.com/mirrors/electron/'
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
|
||||
function resolveElectronPackageRoot() {
|
||||
try {
|
||||
return path.dirname(require.resolve('electron/package.json'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* .npmrc 的 electron_mirror 只有在 npm/pnpm 执行脚本时才会变成 npm_config_* 环境变量;
|
||||
* 直接 `node node_modules/electron/install.js` 时读不到,于是会绕开镜像去打 GitHub。
|
||||
* 这里显式读出来当 ELECTRON_MIRROR 传下去。显式设置的环境变量优先。
|
||||
*/
|
||||
function resolveMirror() {
|
||||
if (process.env.ELECTRON_MIRROR) return process.env.ELECTRON_MIRROR
|
||||
try {
|
||||
const npmrc = fs.readFileSync(path.join(projectRoot, '.npmrc'), 'utf8')
|
||||
const match = npmrc.match(/^\s*electron_mirror\s*=\s*(\S+)\s*$/m)
|
||||
return match ? match[1] : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* path.txt 只是 electron 写下的相对路径,光有它不算装好 —— 指向的可执行文件
|
||||
* 必须真的存在,否则仍会在启动时报「Electron failed to install correctly」。
|
||||
*/
|
||||
function isElectronBinaryInstalled(packageRoot) {
|
||||
if (!packageRoot) return false
|
||||
try {
|
||||
const executable = fs.readFileSync(path.join(packageRoot, 'path.txt'), 'utf8').trim()
|
||||
return executable !== '' && fs.existsSync(path.join(packageRoot, 'dist', executable))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function runInstaller(packageRoot) {
|
||||
const installer = path.join(packageRoot, 'install.js')
|
||||
if (!fs.existsSync(installer)) {
|
||||
throw new Error(`[ensure-electron] missing ${installer}; run pnpm install first`)
|
||||
}
|
||||
const mirror = resolveMirror()
|
||||
const attempts = mirror
|
||||
? [{ label: mirror, env: { ELECTRON_MIRROR: mirror } }]
|
||||
: [{ label: 'default source', env: {} }]
|
||||
// 配的镜像本身不是 npmmirror 时,再兜一层:镜像挂掉时不至于完全没退路。
|
||||
if (!mirror.includes('npmmirror.com')) {
|
||||
attempts.push({ label: MIRROR_FALLBACK, env: { ELECTRON_MIRROR: MIRROR_FALLBACK } })
|
||||
}
|
||||
let lastError
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
const attempt = attempts[index]
|
||||
console.log(`[ensure-electron] downloading from ${attempt.label}`)
|
||||
try {
|
||||
execFileSync(process.execPath, [installer], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...attempt.env }
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const next = attempts[index + 1]
|
||||
if (next) {
|
||||
console.warn(`[ensure-electron] download failed, retrying from ${next.label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function ensureElectronBinary() {
|
||||
const packageRoot = resolveElectronPackageRoot()
|
||||
if (isElectronBinaryInstalled(packageRoot)) return false
|
||||
console.log('[ensure-electron] Electron binary is missing; downloading it now')
|
||||
runInstaller(packageRoot)
|
||||
if (!isElectronBinaryInstalled(packageRoot)) {
|
||||
throw new Error('[ensure-electron] Electron binary is still missing after installing')
|
||||
}
|
||||
console.log('[ensure-electron] Electron binary is ready')
|
||||
return true
|
||||
}
|
||||
|
||||
if (require.main === module) ensureElectronBinary()
|
||||
|
||||
module.exports = { ensureElectronBinary, isElectronBinaryInstalled, resolveElectronPackageRoot }
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||
|
||||
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
||||
|
||||
@@ -24,6 +25,31 @@ function readOption(name, fallback) {
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
||||
}
|
||||
|
||||
function ffmpegExecutableName(targetPlatform) {
|
||||
return targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg-static keeps a single binary per platform ("ffmpeg" everywhere except
|
||||
* Windows), so an arm64 and an x64 macOS checkout cannot coexist in
|
||||
* node_modules. Its installer also exits early whenever the file already
|
||||
* exists, so a binary left over from the other architecture would be packed
|
||||
* silently. Check the real architecture and drop the file when it differs, so
|
||||
* the caller re-downloads the requested one.
|
||||
*/
|
||||
function ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch) {
|
||||
if (!fs.existsSync(ffmpegPath)) return 'missing'
|
||||
const architectures = readBinaryArchitectures(ffmpegPath)
|
||||
if (architectures.includes(targetArch)) return 'match'
|
||||
console.log(
|
||||
`[prepare-electron-runtime] ffmpeg-static is ${
|
||||
architectures.join('/') || 'not a native binary'
|
||||
} but ${targetPlatform}-${targetArch} was requested; replacing it`
|
||||
)
|
||||
fs.rmSync(ffmpegPath, { force: true })
|
||||
return 'replaced'
|
||||
}
|
||||
|
||||
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
||||
let packageRoot = ''
|
||||
try {
|
||||
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const executable = ffmpegExecutableName(targetPlatform)
|
||||
const ffmpegPath = path.join(packageRoot, executable)
|
||||
const architectureState = ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch)
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
if (architectureState !== 'match') {
|
||||
const installScript = path.join(packageRoot, 'install.js')
|
||||
console.log(
|
||||
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
||||
@@ -83,4 +110,6 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { ensureFfmpegArchitecture, ffmpegExecutableName, prepareFfmpegRuntime }
|
||||
|
||||
@@ -390,9 +390,11 @@ export class PersonalWechatRuntimeManager {
|
||||
return this.buildStatus(
|
||||
'unsupported',
|
||||
0,
|
||||
process.platform === 'win32'
|
||||
? 'Windows 暂不支持个人微信发送组件'
|
||||
: `当前系统暂不支持个人微信发送组件:${process.platform} ${process.arch}`
|
||||
process.platform === 'darwin'
|
||||
? 'Intel Mac 不支持个人微信发送组件'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows 暂不支持个人微信发送组件'
|
||||
: '当前系统不支持个人微信发送组件'
|
||||
)
|
||||
}
|
||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||
|
||||
@@ -1630,7 +1630,10 @@ export class PersonalWechatSendService {
|
||||
status: {
|
||||
...base,
|
||||
state: 'unsupported_platform',
|
||||
message: '个人微信发送当前仅支持 Apple Silicon Mac'
|
||||
message:
|
||||
process.platform === 'darwin'
|
||||
? 'Intel Mac 不支持个人微信发送'
|
||||
: '当前系统不支持个人微信发送'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,11 +414,13 @@ export function PersonalWechatSendPage({
|
||||
<span className="settings-card-kicker">个人微信</span>
|
||||
<strong className="mt-1 block text-base">{pageMessage}</strong>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{pageStatus === 'unsupported' && !isWindows
|
||||
? '微信消息发送目前仅支持 macOS 和 Windows。'
|
||||
: isWindows
|
||||
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
||||
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
||||
{pageStatus === 'unsupported' && isMac
|
||||
? 'Intel Mac 不支持个人微信发送。'
|
||||
: pageStatus === 'unsupported' && !isWindows
|
||||
? '微信消息发送目前仅支持 macOS 和 Windows。'
|
||||
: isWindows
|
||||
? 'Windows 通过本机微信发送接口工作,请先配置并检测接口端口。'
|
||||
: '档案中的文字、图片和语音发送,以及定时日报发送,都会使用这项能力。'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createRequire } from 'module'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const { isElectronBinaryInstalled } = nodeRequire('../../scripts/ensure-electron-binary.cjs') as {
|
||||
isElectronBinaryInstalled: (packageRoot: string) => boolean
|
||||
}
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-electron-binary-'))
|
||||
|
||||
function fixture(name: string, pathTxt: string | null, executable: string | null): string {
|
||||
const packageRoot = join(root, name)
|
||||
mkdirSync(packageRoot, { recursive: true })
|
||||
if (pathTxt !== null) writeFileSync(join(packageRoot, 'path.txt'), pathTxt)
|
||||
if (executable !== null) {
|
||||
const file = join(packageRoot, 'dist', executable)
|
||||
mkdirSync(join(packageRoot, 'dist', ...executable.split('/').slice(0, -1)), { recursive: true })
|
||||
writeFileSync(file, 'fixture')
|
||||
}
|
||||
return packageRoot
|
||||
}
|
||||
|
||||
describe('electron binary readiness', () => {
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
it('treats an empty shell as not installed', () => {
|
||||
// electron@43 ships no postinstall hook, so pnpm install leaves exactly this.
|
||||
expect(isElectronBinaryInstalled(fixture('empty-shell', null, null))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not trust path.txt when the executable is missing', () => {
|
||||
const packageRoot = fixture('half-installed', 'Electron.app/Contents/MacOS/Electron', null)
|
||||
expect(isElectronBinaryInstalled(packageRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a completed install', () => {
|
||||
const packageRoot = fixture(
|
||||
'installed',
|
||||
'Electron.app/Contents/MacOS/Electron',
|
||||
'Electron.app/Contents/MacOS/Electron'
|
||||
)
|
||||
expect(isElectronBinaryInstalled(packageRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects blank paths and missing package roots', () => {
|
||||
expect(isElectronBinaryInstalled(fixture('blank-path', ' ', 'Electron'))).toBe(false)
|
||||
expect(isElectronBinaryInstalled('')).toBe(false)
|
||||
expect(isElectronBinaryInstalled(join(root, 'does-not-exist'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRequire } from 'module'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join, resolve } from 'path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
@@ -26,6 +26,48 @@ const {
|
||||
}
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-'))
|
||||
|
||||
const {
|
||||
pruneIntelMacKeyTool,
|
||||
pruneForeignArchConnectors,
|
||||
pruneForeignArchNativeRuntimes,
|
||||
validateRuntimeBinaryArchitecture
|
||||
} = nodeRequire('../../scripts/after-pack.cjs') as {
|
||||
pruneIntelMacKeyTool: (
|
||||
runtimeResources: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string
|
||||
) => string | null
|
||||
pruneForeignArchConnectors: (
|
||||
runtimeResources: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string
|
||||
) => string[]
|
||||
pruneForeignArchNativeRuntimes: (
|
||||
runtimeResources: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string
|
||||
) => string[]
|
||||
validateRuntimeBinaryArchitecture: (
|
||||
filePath: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string,
|
||||
label: string
|
||||
) => void
|
||||
}
|
||||
const { readBinaryArchitectures } = nodeRequire('../../scripts/binary-arch.cjs') as {
|
||||
readBinaryArchitectures: (filePath: string) => string[]
|
||||
}
|
||||
|
||||
const CPU_TYPE_X86_64 = 0x01000007
|
||||
const CPU_TYPE_ARM64 = 0x0100000c
|
||||
|
||||
function writeThinMachO(filePath: string, cpuType: number): void {
|
||||
const buffer = Buffer.alloc(32)
|
||||
buffer.writeUInt32LE(0xfeedfacf, 0)
|
||||
buffer.writeUInt32LE(cpuType, 4)
|
||||
writeFileSync(filePath, buffer)
|
||||
}
|
||||
|
||||
describe('production runtime packaging', () => {
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
@@ -177,3 +219,107 @@ describe('production runtime packaging', () => {
|
||||
expect(config).toContain('node_modules/sherpa-onnx-*/**')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-architecture macOS packaging', () => {
|
||||
const archRoot = mkdtempSync(join(tmpdir(), 'wxe-runtime-arch-'))
|
||||
afterAll(() => rmSync(archRoot, { recursive: true, force: true }))
|
||||
|
||||
it('reads architectures out of Mach-O and PE binaries', () => {
|
||||
const x64 = join(archRoot, 'fixture-x64')
|
||||
const arm64 = join(archRoot, 'fixture-arm64')
|
||||
writeThinMachO(x64, CPU_TYPE_X86_64)
|
||||
writeThinMachO(arm64, CPU_TYPE_ARM64)
|
||||
|
||||
expect(readBinaryArchitectures(x64)).toEqual(['x64'])
|
||||
expect(readBinaryArchitectures(arm64)).toEqual(['arm64'])
|
||||
expect(readBinaryArchitectures(resolve(__dirname, 'runtime-packaging.test.ts'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the Intel Mac key helper only in x64 macOS bundles', () => {
|
||||
const resources = join(archRoot, 'key-tool', 'Contents', 'Resources')
|
||||
const keyToolDirectory = join(resources, 'resources', 'macos-key-tool')
|
||||
const helper = join(keyToolDirectory, 'intel_mac_key_helper')
|
||||
mkdirSync(keyToolDirectory, { recursive: true })
|
||||
writeFileSync(helper, 'fixture')
|
||||
|
||||
expect(pruneIntelMacKeyTool(resources, 'darwin', 'x64')).toBe(helper)
|
||||
expect(existsSync(helper)).toBe(true)
|
||||
|
||||
expect(pruneIntelMacKeyTool(resources, 'darwin', 'arm64')).toBeNull()
|
||||
expect(existsSync(keyToolDirectory)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails an x64 bundle that lost the Intel Mac key helper', () => {
|
||||
const resources = join(archRoot, 'key-tool-missing', 'Contents', 'Resources')
|
||||
mkdirSync(resources, { recursive: true })
|
||||
|
||||
expect(() => pruneIntelMacKeyTool(resources, 'darwin', 'x64')).toThrow(/macos-key-tool/)
|
||||
expect(pruneIntelMacKeyTool(resources, 'win32', 'x64')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops bundled connectors built for another architecture', () => {
|
||||
const resources = join(archRoot, 'connectors', 'Contents', 'Resources')
|
||||
const foreign = join(resources, 'resources', 'connectors', 'wechat', 'darwin-arm64')
|
||||
const matching = join(resources, 'resources', 'connectors', 'wechat', 'darwin-x64')
|
||||
mkdirSync(foreign, { recursive: true })
|
||||
mkdirSync(matching, { recursive: true })
|
||||
|
||||
expect(pruneForeignArchConnectors(resources, 'darwin', 'x64')).toEqual(['wechat/darwin-arm64'])
|
||||
expect(existsSync(foreign)).toBe(false)
|
||||
expect(existsSync(matching)).toBe(true)
|
||||
|
||||
expect(pruneForeignArchConnectors(resources, 'darwin', 'universal')).toEqual([])
|
||||
expect(existsSync(matching)).toBe(true)
|
||||
})
|
||||
|
||||
it('drops native runtime packages built for other platforms', () => {
|
||||
const resources = join(archRoot, 'native-runtimes', 'Contents', 'Resources')
|
||||
const modules = join(resources, 'app.asar.unpacked', 'node_modules')
|
||||
for (const name of [
|
||||
'sherpa-onnx-darwin-x64',
|
||||
'sherpa-onnx-darwin-arm64',
|
||||
'sherpa-onnx-linux-x64',
|
||||
'sherpa-onnx-node'
|
||||
]) {
|
||||
mkdirSync(join(modules, name), { recursive: true })
|
||||
}
|
||||
for (const name of ['koffi-darwin-x64', 'koffi-win32-x64']) {
|
||||
mkdirSync(join(modules, '@koromix', name), { recursive: true })
|
||||
}
|
||||
|
||||
expect(pruneForeignArchNativeRuntimes(resources, 'darwin', 'x64').sort()).toEqual([
|
||||
'@koromix/koffi-win32-x64',
|
||||
'sherpa-onnx-darwin-arm64',
|
||||
'sherpa-onnx-linux-x64'
|
||||
])
|
||||
expect(existsSync(join(modules, 'sherpa-onnx-darwin-x64'))).toBe(true)
|
||||
expect(existsSync(join(modules, 'sherpa-onnx-node'))).toBe(true)
|
||||
expect(existsSync(join(modules, '@koromix', 'koffi-darwin-x64'))).toBe(true)
|
||||
expect(existsSync(join(modules, '@koromix', 'koffi-win32-x64'))).toBe(false)
|
||||
|
||||
expect(pruneForeignArchNativeRuntimes(resources, 'darwin', 'universal')).toEqual([])
|
||||
expect(existsSync(join(modules, 'sherpa-onnx-darwin-x64'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a bundled binary built for another architecture', () => {
|
||||
const binary = join(archRoot, 'bundled-arm64')
|
||||
writeThinMachO(binary, CPU_TYPE_ARM64)
|
||||
|
||||
expect(() =>
|
||||
validateRuntimeBinaryArchitecture(binary, 'darwin', 'x64', 'Bundled ffmpeg')
|
||||
).toThrow(/arm64 but this bundle targets x64/)
|
||||
expect(() =>
|
||||
validateRuntimeBinaryArchitecture(binary, 'darwin', 'arm64', 'Bundled ffmpeg')
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('release publishing policy', () => {
|
||||
it('uploads GitHub releases as drafts until the notes are reviewed', () => {
|
||||
for (const configFile of ['electron-builder.yml', 'electron-builder.win.yml']) {
|
||||
const config = readFileSync(resolve(__dirname, `../../${configFile}`), 'utf8')
|
||||
expect(config).toContain('releaseType: draft')
|
||||
expect(config).not.toContain('releaseType: release')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user