Jelajahi Sumber

修复桌面包浏览器依赖遗漏并增加签名前验收

Samien 1 bulan lalu
induk
melakukan
b14ca91ff6

+ 11 - 2
electron-builder.yml

@@ -1,5 +1,6 @@
 appId: cn.samien.yaoyao.desktop
 appId: cn.samien.yaoyao.desktop
 productName: 夭夭
 productName: 夭夭
+afterPack: scripts/verify-desktop-runtime.mjs
 directories:
 directories:
   output: desktop-release
   output: desktop-release
   app: .desktop-build/shell
   app: .desktop-build/shell
@@ -13,8 +14,6 @@ extraResources:
       - server.mjs.map
       - server.mjs.map
       - runner.mjs
       - runner.mjs
       - runner.mjs.map
       - runner.mjs.map
-      - node_modules/playwright/**/*
-      - node_modules/playwright-core/**/*
       - runner-image.mjs
       - runner-image.mjs
       - runner-maintenance.mjs
       - runner-maintenance.mjs
       - computer-image/**/*
       - computer-image/**/*
@@ -36,6 +35,16 @@ extraResources:
       - computer-helper.exe
       - computer-helper.exe
       - ui/**/*
       - ui/**/*
       - web-service/**/*
       - web-service/**/*
+  # electron-builder skips node_modules at a resource source root, even when
+  # globbed explicitly. Each package needs its own copy source.
+  - from: .desktop-build/node_modules/playwright
+    to: runtime/node_modules/playwright
+    filter:
+      - '**/*'
+  - from: .desktop-build/node_modules/playwright-core
+    to: runtime/node_modules/playwright-core
+    filter:
+      - '**/*'
 asar: true
 asar: true
 npmRebuild: false
 npmRebuild: false
 win:
 win:

+ 44 - 0
scripts/verify-desktop-runtime.mjs

@@ -0,0 +1,44 @@
+import {execFileSync} from 'node:child_process'
+import {readFileSync} from 'node:fs'
+import {join} from 'node:path'
+import {verifyRuntimePackage} from '../bin/lib/runtime-release.mjs'
+
+/** Run before signing: a valid build directory does not prove extraResources
+ * retained the Runner's external packages in the actual application. */
+export function verifyDesktopRuntime(runtime,version){
+  const service=join(runtime,'web-service'),identity=verifyRuntimePackage(service)
+  const probe=String.raw`
+    import {createRequire} from 'node:module';
+    import {readFileSync,realpathSync,statSync} from 'node:fs';
+    import {dirname,join,relative,isAbsolute} from 'node:path';
+    import {pathToFileURL} from 'node:url';
+    const {runtime,version}=JSON.parse(process.argv[1]);
+    for(const [base,entry] of [[runtime,'runner.mjs'],[join(runtime,'web-service'),'server.mjs']]){
+      if(!statSync(join(base,entry)).isFile())throw new Error('应用内执行入口缺失:'+entry);
+      const require=createRequire(join(base,entry));
+      for(const name of ['playwright','playwright-core']){
+        const path=realpathSync(require.resolve(name+'/package.json'));
+        const local=relative(realpathSync(join(base,'node_modules',name)),path);
+        if(local.startsWith('..')||isAbsolute(local))throw new Error('浏览器依赖解析到了应用外部:'+name);
+        const pkg=JSON.parse(readFileSync(path,'utf8'));
+        if(pkg.version!==version)throw new Error('浏览器运行包版本不一致:'+name);
+        if(name==='playwright'){
+          const {chromium}=await import(pathToFileURL(join(dirname(path),'index.mjs')).href);
+          if(!chromium?.executablePath())throw new Error('浏览器运行包无法加载');
+        }else if(!JSON.parse(readFileSync(join(dirname(path),'browsers.json'),'utf8')).browsers?.length){
+          throw new Error('浏览器运行包缺少安装清单');
+        }
+      }
+    }
+  `
+  execFileSync(join(service,'node'),['--input-type=module','-e',probe,JSON.stringify({runtime,version})],{cwd:runtime,stdio:['ignore','pipe','pipe'],timeout:30000})
+  return identity
+}
+
+export default async function verifyPackagedDesktop(context){
+  if(context.electronPlatformName!=='darwin')return
+  const version=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).dependencies.playwright
+  const runtime=join(context.appOutDir,context.packager.appInfo.productFilename+'.app','Contents','Resources','runtime')
+  verifyDesktopRuntime(runtime,version)
+  console.log('签名前验证通过:应用内 Runner / Web 浏览器依赖与服务完整性。')
+}

+ 41 - 8
tests/runner/browserPackaging.test.ts

@@ -1,22 +1,55 @@
 // @vitest-environment node
 // @vitest-environment node
 import {expect,it} from 'vitest'
 import {expect,it} from 'vitest'
 import {build} from 'esbuild'
 import {build} from 'esbuild'
-import {mkdtemp,rm,readdir,readFile} from 'node:fs/promises'
+import {mkdtemp,rm,readdir,readFile,copyFile,mkdir,writeFile,chmod} from 'node:fs/promises'
 import {tmpdir} from 'node:os'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {join} from 'node:path'
 import {execFileSync} from 'node:child_process'
 import {execFileSync} from 'node:child_process'
+import {createRequire} from 'node:module'
+import {load} from 'js-yaml'
 // @ts-expect-error Build helper is plain JavaScript.
 // @ts-expect-error Build helper is plain JavaScript.
 import {browserRuntimeExternals,bundleBrowserRuntime} from '../../scripts/bundle-browser-runtime.mjs'
 import {browserRuntimeExternals,bundleBrowserRuntime} from '../../scripts/bundle-browser-runtime.mjs'
+// @ts-expect-error Build verification helper is plain JavaScript.
+import {verifyDesktopRuntime} from '../../scripts/verify-desktop-runtime.mjs'
+// @ts-expect-error Sealed runtime helper is plain JavaScript.
+import {sealRuntime} from '../../bin/lib/runtime-release.mjs'
 
 
-it('imports the relocated browser runtime without the repository node_modules or copied Chromium caches',async()=>{
+it('imports Playwright from electron-builder copied Runner and Web resources without repository dependencies or Chromium caches',async()=>{
   const directory=await mkdtemp(join(tmpdir(),'yaoyao-browser-package-'))
   const directory=await mkdtemp(join(tmpdir(),'yaoyao-browser-package-'))
   try{
   try{
-    await build({stdin:{contents:'const {chromium}=await import("playwright"); console.log(JSON.stringify({path:chromium.executablePath(),version:(await import("playwright/package.json",{with:{type:"json"}})).default.version}))',resolveDir:process.cwd(),sourcefile:'browser-probe.mjs'},outfile:join(directory,'browser-probe.mjs'),bundle:true,platform:'node',format:'esm',target:'node24',external:browserRuntimeExternals})
-    await bundleBrowserRuntime(directory)
-    const result=JSON.parse(execFileSync(process.execPath,[join(directory,'browser-probe.mjs')],{cwd:directory,encoding:'utf8'}))
+    const staging=join(directory,'.desktop-build'),resources=join(directory,'packaged-app','Contents','Resources')
+    await build({stdin:{contents:'const {chromium}=await import("playwright"); console.log(JSON.stringify({path:chromium.executablePath(),version:(await import("playwright/package.json",{with:{type:"json"}})).default.version,resolved:import.meta.resolve("playwright")}))',resolveDir:process.cwd(),sourcefile:'browser-probe.mjs'},outfile:join(staging,'runner.mjs'),bundle:true,platform:'node',format:'esm',target:'node24',external:browserRuntimeExternals})
+    await bundleBrowserRuntime(staging)
+    const service=join(staging,'web-service')
+    await bundleBrowserRuntime(service)
+    await copyFile(join(staging,'runner.mjs'),join(service,'server.mjs'))
+    // A minimal sealed service fixture uses the real Node binary and browser
+    // packages; it does not depend on a developer's previous desktop build.
+    await copyFile(process.execPath,join(service,'node'));await chmod(join(service,'node'),0o755)
+    await mkdir(join(service,'ui'));await writeFile(join(service,'ui/index.html'),'<!doctype html>Fixture')
+    await mkdir(join(service,'bin'))
+    for(const name of ['hermes-yaoyao.mjs','hermes-yaoyao-updater.mjs'])await writeFile(join(service,'bin',name),'// packaging fixture\n')
+    await writeFile(join(service,'build-info.json'),JSON.stringify({commit:'packaging-fixture',ancestors:[],buildNumber:1,dirty:false}))
+    await writeFile(join(service,'release.json'),JSON.stringify({webVersion:'0.0.1',releaseVersion:'0.0.1',gitTag:'v0.0.1'}))
+    sealRuntime(service)
+    const configuration=load(await readFile(new URL('../../electron-builder.yml',import.meta.url),'utf8'))
+    // Exercise the real packaging walker, which intentionally skips a source's
+    // root node_modules. Plain glob matching did not catch the missing packages.
+    const {getFileMatchers,copyFiles}=createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js')
+    const matchers=getFileMatchers(configuration,'extraResources',resources,{defaultSrc:directory,macroExpander:(value:string)=>value,customBuildOptions:{},globalOutDir:join(directory,'desktop-release')})
+    await copyFiles(matchers)
     const manifest=JSON.parse(await readFile(new URL('../../package.json',import.meta.url),'utf8'))
     const manifest=JSON.parse(await readFile(new URL('../../package.json',import.meta.url),'utf8'))
-    expect(result.version).toBe(manifest.dependencies.playwright);expect(result.path).toContain('chromium')
-    expect(await readdir(join(directory,'node_modules'))).toEqual(['playwright','playwright-core'])
-    expect(await readdir(join(directory,'node_modules','playwright-core'))).not.toContain('.local-browsers')
+    for(const [relative,entry] of [['runtime','runner.mjs'],['runtime/web-service','server.mjs']]){
+      const runtime=join(resources,relative!),result=JSON.parse(execFileSync(process.execPath,[join(runtime,entry!)],{cwd:directory,encoding:'utf8'}))
+      expect(result.version).toBe(manifest.dependencies.playwright);expect(result.path).toContain('chromium')
+      expect(result.resolved).toContain('/packaged-app/Contents/Resources/'+relative+'/node_modules/playwright/')
+      expect(await readdir(join(runtime,'node_modules'))).toEqual(['playwright','playwright-core'])
+      expect(await readdir(join(runtime,'node_modules','playwright-core'))).not.toContain('.local-browsers')
+      expect(JSON.parse(await readFile(join(runtime,'node_modules/playwright-core/browsers.json'),'utf8')).browsers.some((browser:{name:string})=>browser.name==='chromium')).toBe(true)
+    }
+    const packagedRuntime=join(resources,'runtime')
+    expect(verifyDesktopRuntime(packagedRuntime,manifest.dependencies.playwright)).toMatchObject({commit:'packaging-fixture',version:'0.0.1',dirty:false})
+    await rm(join(packagedRuntime,'node_modules','playwright'),{recursive:true,force:true})
+    expect(()=>verifyDesktopRuntime(packagedRuntime,manifest.dependencies.playwright)).toThrow()
   }finally{await rm(directory,{recursive:true,force:true})}
   }finally{await rm(directory,{recursive:true,force:true})}
 },20000)
 },20000)