mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
fix(desktop): sign embedded backend via mac.binaries for notarization
This commit is contained in:
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -108,8 +108,6 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
# Signing identity name for the backend-signing afterPack hook.
|
|
||||||
MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
@@ -125,9 +123,6 @@ jobs:
|
|||||||
if [ -n "$MAC_CSC_LINK" ]; then
|
if [ -n "$MAC_CSC_LINK" ]; then
|
||||||
export CSC_LINK="$MAC_CSC_LINK"
|
export CSC_LINK="$MAC_CSC_LINK"
|
||||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||||
# Identity name used by the afterPack hook to sign the embedded
|
|
||||||
# PyInstaller backend (extraResources aren't signed by default).
|
|
||||||
export CSC_NAME="$MAC_CSC_NAME"
|
|
||||||
fi
|
fi
|
||||||
if [ -z "$WIN_CSC_LINK" ]; then
|
if [ -z "$WIN_CSC_LINK" ]; then
|
||||||
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
||||||
@@ -137,7 +132,9 @@ jobs:
|
|||||||
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
||||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# `--publish never` still emits the latest*.yml files we parse for sha512.
|
# `--publish never` still emits the latest*.yml files we parse for sha512.
|
||||||
npx electron-builder ${{ matrix.eb_flags }} --publish never
|
# Use the dynamic config (electron-builder.js) so mac.binaries is
|
||||||
|
# populated with the embedded backend's Mach-O files for signing.
|
||||||
|
npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -54,7 +54,6 @@ desktop/build/*
|
|||||||
!desktop/build/cowagent-backend.spec
|
!desktop/build/cowagent-backend.spec
|
||||||
!desktop/build/requirements-desktop.txt
|
!desktop/build/requirements-desktop.txt
|
||||||
!desktop/build/build-backend.sh
|
!desktop/build/build-backend.sh
|
||||||
!desktop/build/sign-backend.js
|
|
||||||
!desktop/build/entitlements.mac.plist
|
!desktop/build/entitlements.mac.plist
|
||||||
|
|
||||||
# Icon authoring scratch dir: intermediate assets used to produce the final
|
# Icon authoring scratch dir: intermediate assets used to produce the final
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* electron-builder afterPack hook.
|
|
||||||
*
|
|
||||||
* The Python backend is a PyInstaller onedir bundle shipped via extraResources
|
|
||||||
* into Contents/Resources/backend/. electron-builder's deep signing does NOT
|
|
||||||
* cover extraResources, so its executable + hundreds of .so/.dylib files stay
|
|
||||||
* unsigned — which makes notarization reject the whole app.
|
|
||||||
*
|
|
||||||
* This hook runs before electron-builder signs the .app: it code-signs every
|
|
||||||
* Mach-O file under the backend dir with hardened runtime + entitlements, so
|
|
||||||
* the outer app signature and notarization succeed.
|
|
||||||
*/
|
|
||||||
const { execFileSync, execSync } = require('child_process')
|
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
exports.default = async function signBackend(context) {
|
|
||||||
const { electronPlatformName, appOutDir, packager } = context
|
|
||||||
if (electronPlatformName !== 'darwin') return
|
|
||||||
|
|
||||||
// Signing identity comes from CSC_NAME (set in CI and, for local builds,
|
|
||||||
// your shell). No identity => unsigned build (e.g. dry runs), so skip the
|
|
||||||
// backend signing rather than fail.
|
|
||||||
const cscName = process.env.CSC_NAME
|
|
||||||
if (!cscName) {
|
|
||||||
console.log('[sign-backend] CSC_NAME not set — unsigned build, skipping backend signing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve CSC_NAME to a certificate SHA-1 hash. In CI electron-builder
|
|
||||||
// imports the cert into a temporary keychain (not login.keychain), so
|
|
||||||
// matching by name is unreliable — `codesign` may not find it. The SHA-1 is
|
|
||||||
// unambiguous and works across the whole keychain search list.
|
|
||||||
//
|
|
||||||
// `security find-identity -v -p codesigning` searches every keychain on the
|
|
||||||
// user search list (including the temp one electron-builder registers) and
|
|
||||||
// prints lines like: 1) <SHA1> "Developer ID Application: ... (TEAMID)"
|
|
||||||
let identity = cscName
|
|
||||||
try {
|
|
||||||
const idList = execFileSync(
|
|
||||||
'security',
|
|
||||||
['find-identity', '-v', '-p', 'codesigning'],
|
|
||||||
{ encoding: 'utf8' }
|
|
||||||
)
|
|
||||||
for (const line of idList.split('\n')) {
|
|
||||||
const m = line.match(/\)\s+([0-9A-F]{40})\s+"(.+)"/i)
|
|
||||||
if (m && m[2] === cscName) {
|
|
||||||
identity = m[1]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[sign-backend] could not resolve identity hash, falling back to name')
|
|
||||||
}
|
|
||||||
console.log(`[sign-backend] using signing identity: ${identity}`)
|
|
||||||
|
|
||||||
const appName = packager.appInfo.productFilename
|
|
||||||
const backendDir = path.join(
|
|
||||||
appOutDir,
|
|
||||||
`${appName}.app`,
|
|
||||||
'Contents',
|
|
||||||
'Resources',
|
|
||||||
'backend'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!fs.existsSync(backendDir)) {
|
|
||||||
console.log(`[sign-backend] no backend dir at ${backendDir}, skipping`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entitlements = path.join(__dirname, 'entitlements.mac.plist')
|
|
||||||
|
|
||||||
// Collect every Mach-O file (executables + dylibs/so) under backend/.
|
|
||||||
// `file` output contains "Mach-O" for native binaries.
|
|
||||||
const files = []
|
|
||||||
const walk = (dir) => {
|
|
||||||
for (const name of fs.readdirSync(dir)) {
|
|
||||||
const full = path.join(dir, name)
|
|
||||||
const stat = fs.lstatSync(full)
|
|
||||||
if (stat.isSymbolicLink()) continue
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
walk(full)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const out = execSync(`file -b "${full}"`, { encoding: 'utf8' })
|
|
||||||
if (out.includes('Mach-O')) files.push(full)
|
|
||||||
} catch {
|
|
||||||
// ignore unreadable entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(backendDir)
|
|
||||||
|
|
||||||
console.log(`[sign-backend] signing ${files.length} Mach-O files under backend/`)
|
|
||||||
|
|
||||||
// Sign inner libraries first, then the main executable last (inside-out).
|
|
||||||
files.sort((a, b) => b.length - a.length)
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
execFileSync(
|
|
||||||
'codesign',
|
|
||||||
[
|
|
||||||
'--force',
|
|
||||||
'--timestamp',
|
|
||||||
'--options',
|
|
||||||
'runtime',
|
|
||||||
'--entitlements',
|
|
||||||
entitlements,
|
|
||||||
'--sign',
|
|
||||||
identity,
|
|
||||||
f,
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[sign-backend] backend signing complete')
|
|
||||||
}
|
|
||||||
77
desktop/electron-builder.js
Normal file
77
desktop/electron-builder.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic electron-builder config.
|
||||||
|
*
|
||||||
|
* We keep the base config in package.json's "build" field and extend it here
|
||||||
|
* only to populate `mac.binaries` — the list of extra Mach-O files that must
|
||||||
|
* be signed with hardened runtime + entitlements.
|
||||||
|
*
|
||||||
|
* Why this is needed:
|
||||||
|
* The Python backend is a PyInstaller onedir bundle shipped via extraResources
|
||||||
|
* into Contents/Resources/backend/. electron-builder only hands the top-level
|
||||||
|
* `.app` to codesign, which does NOT deep-sign the ~180 nested .so/.dylib
|
||||||
|
* files under Resources/. Left unsigned (or without hardened runtime), Apple
|
||||||
|
* notarization rejects the whole app.
|
||||||
|
*
|
||||||
|
* `mac.binaries` is the officially supported way to sign extra binaries: they
|
||||||
|
* are signed inside electron-builder's own signing pass, AFTER it has created
|
||||||
|
* the temporary keychain and imported the Developer ID cert (from CSC_LINK).
|
||||||
|
* A previous afterPack approach failed because afterPack runs BEFORE that
|
||||||
|
* keychain exists, so `codesign` couldn't find the identity.
|
||||||
|
*
|
||||||
|
* Paths are resolved relative to the `.app` at signing time. We enumerate the
|
||||||
|
* pre-build backend source dir (build/dist/cowagent-backend, produced by
|
||||||
|
* PyInstaller before packaging) — its layout mirrors the in-app copy — and map
|
||||||
|
* each Mach-O to its in-app relative path.
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const config = require('./package.json').build
|
||||||
|
|
||||||
|
// PyInstaller output that gets copied into the app at
|
||||||
|
// Contents/Resources/backend/cowagent-backend (see extraResources).
|
||||||
|
const backendSrc = path.join(__dirname, 'build', 'dist', 'cowagent-backend')
|
||||||
|
const inAppPrefix = path.join('Contents', 'Resources', 'backend', 'cowagent-backend')
|
||||||
|
|
||||||
|
function isMachO(file) {
|
||||||
|
try {
|
||||||
|
return execFileSync('file', ['-b', file], { encoding: 'utf8' }).includes('Mach-O')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBackendBinaries() {
|
||||||
|
if (!fs.existsSync(backendSrc)) {
|
||||||
|
console.warn(`[electron-builder.js] backend not found at ${backendSrc}; mac.binaries left empty`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const rels = []
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
const full = path.join(dir, name)
|
||||||
|
const st = fs.lstatSync(full)
|
||||||
|
if (st.isSymbolicLink()) continue
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
walk(full)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isMachO(full)) {
|
||||||
|
// Map source path -> in-app relative path (resolved against the .app).
|
||||||
|
const rel = path.relative(backendSrc, full)
|
||||||
|
rels.push(path.join(inAppPrefix, rel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(backendSrc)
|
||||||
|
return rels
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const binaries = collectBackendBinaries()
|
||||||
|
console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`)
|
||||||
|
config.mac = { ...config.mac, binaries }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.cowagent.desktop",
|
"appId": "com.cowagent.desktop",
|
||||||
"productName": "CowAgent",
|
"productName": "CowAgent",
|
||||||
"afterPack": "build/sign-backend.js",
|
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user