Share installed desktop and browser assets (#31010)

* Share installed browser assets with the desktop renderer

* Update packaged runtime fixtures for shared renderer assets

* Verify retained cloud clients in the shared renderer layout
This commit is contained in:
Neil
2026-10-19 22:31:59 -07:00
committed by GitHub
parent 84a4526508
commit 937d2d3806
15 changed files with 331 additions and 14 deletions
+4 -2
View File
@@ -302,6 +302,8 @@ module.exports = {
'!out/**/*.map',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
// The installed browser serves its allowlisted assets from the unpacked renderer.
'!out/web{,/**/*}',
// Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each).
// CI never creates it, but packaging on a machine that has run dev would pack them all.
'!out/electron-dev{,/**/*}',
@@ -356,7 +358,7 @@ module.exports = {
// entries reached solely from the Electron main process stay packed, since
// asar redirects their app.asar paths.
// Why: `orca serve` runs the orcad selection entry under ELECTRON_RUN_AS_NODE too (#27307).
// Why out/web and out/mobile-web: their assets are opened by path, and opening a packed file
// Why out/renderer and out/mobile-web: their assets are opened by path, and opening a packed file
// reads a /tmp extract Electron makes once and never remakes, so a /tmp sweep broke them (#27976).
asarUnpack: [
'out/package.json',
@@ -386,7 +388,7 @@ module.exports = {
'out/main/parcel-watcher-process-entry.js',
'out/main/orcad/orcad-local-serve-selection-entry.js',
'out/main/chunks/**',
'out/web/**',
'out/renderer/**',
'out/mobile-web/**',
'resources/**',
'node_modules/ws/**',
@@ -83,6 +83,10 @@ describe('electron-builder config', () => {
expect(packs(toolingPath)).toBe(false)
}
expect(packs('out/main/index.js')).toBe(true)
expect(packs('out/web/web-index.html')).toBe(false)
expect(packs('out/web/assets/shared.js')).toBe(false)
expect(packs('out/renderer/web-index.html')).toBe(true)
expect(packs('out/renderer/.orca-web-client-files.json')).toBe(true)
})
it.each(['file', 'directory'])('keeps a root notes %s out of app.asar', async (kind) => {
@@ -425,8 +429,9 @@ describe('electron-builder config', () => {
)
const isUnpacked = matcher.createFilter()
for (const webPath of [
'out/web/web-index.html',
'out/web/assets/index-abc123.js',
'out/renderer/web-index.html',
'out/renderer/assets/index-abc123.js',
'out/renderer/.orca-web-client-files.json',
'out/mobile-web/manifest.json',
'out/mobile-web/assets/index-abc123.js'
]) {
@@ -561,11 +561,15 @@ async function seedPackagedServePayload(resourcesDir) {
const entry = join(resourcesDir, 'app.asar.unpacked', 'out', ...SERVE_SELECTION_ENTRY.split('/'))
await mkdir(dirname(entry), { recursive: true })
await writeFile(entry, '')
const webDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'web')
const webDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'renderer')
await mkdir(join(webDir, 'assets'), { recursive: true })
await mkdir(join(resourcesDir, 'app.asar.unpacked', 'out', 'cli'), { recursive: true })
await writeFile(join(webDir, 'web-index.html'), '<script src="./assets/web.js"></script>')
await writeFile(join(webDir, 'assets', 'web.js'), '')
await writeFile(
join(webDir, '.orca-web-client-files.json'),
JSON.stringify(['web-index.html', 'assets/web.js'])
)
}
async function seedBundledRipgrep(resourcesDir) {
@@ -30,7 +30,7 @@ describe('packaged cloud source exclusion', () => {
'out/main/runtime/push/push-host-proof.js',
'out/shared/mobile-push-contract.js',
'out/renderer/index.html',
'out/web/assets/cloud/client.js',
'out/renderer/assets/cloud/client.js',
'resources/cloud/icon.png'
]
try {
@@ -181,6 +181,11 @@ try {
rmSync(webOutput, { force: true, recursive: true })
renameSync(stagingOutput, webOutput)
// The installed browser client shares renderer bytes; only its selected files may be served.
writeFileSync(
join(rendererOutput, '.orca-web-client-files.json'),
JSON.stringify([...selectedFiles].sort())
)
} finally {
rmSync(stagingOutput, { force: true, recursive: true })
}
@@ -103,6 +103,23 @@ describe('renderer web client projection', () => {
expect(existsSync(join(root, 'out/web/assets/logo.png'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/desktop-entry.js'))).toBe(false)
expect(existsSync(join(root, 'out/web/stale.js'))).toBe(false)
const sharedFiles = JSON.parse(
readFileSync(join(root, 'out/renderer/.orca-web-client-files.json'), 'utf8')
)
expect(sharedFiles).toEqual(
expect.arrayContaining([
'web-index.html',
'assets/web-entry.js',
'assets/web-shared.js',
'assets/editor.worker-fixture.js',
'assets/web.css',
'assets/lazy.js',
'assets/logo.png'
])
)
expect(sharedFiles).not.toContain('assets/desktop-entry.js')
expect(sharedFiles).not.toContain('.vite/manifest.json')
expect(sharedFiles).not.toContain('index.html')
expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n')
})
@@ -24,6 +24,7 @@ const REQUIRED_FILES = [
'preload/index.js',
'renderer/index.html',
'renderer/.vite/manifest.json',
'renderer/.orca-web-client-files.json',
'web/web-index.html',
'mobile-web/manifest.json',
'package.json'
@@ -34,6 +34,7 @@ function fixture() {
'preload/index.js',
'renderer/index.html',
'renderer/.vite/manifest.json',
'renderer/.orca-web-client-files.json',
'web/web-index.html',
'mobile-web/manifest.json',
'package.json',
@@ -2,7 +2,7 @@ const { existsSync, readFileSync, statSync } = require('node:fs')
const { dirname, join, posix } = require('node:path')
const WEB_CLIENT_INDEX = 'web-index.html'
const REMEDY = 'Run pnpm build:web (build:desktop and build:release already do).'
const REMEDY = 'Run pnpm build:web-from-renderer (build:desktop and build:release already do).'
function failure(message) {
return new Error(`[verify-packaged-serve-payload] ${message}`)
@@ -27,7 +27,33 @@ function assertPackagedWebClient(resourcesDir) {
if (!existsSync(join(outDir, 'cli'))) {
throw failure(`no unpacked CLI at ${join(outDir, 'cli')}; the web client root resolves from it`)
}
const webRoot = join(outDir, 'web')
const webRoot = join(outDir, 'renderer')
const manifestPath = join(webRoot, '.orca-web-client-files.json')
if (!existsSync(manifestPath)) {
throw failure(`missing unpacked web file manifest at ${manifestPath}. ${REMEDY}`)
}
const files = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (
!Array.isArray(files) ||
!files.includes(WEB_CLIENT_INDEX) ||
!files.every(
(file) =>
typeof file === 'string' &&
(file === WEB_CLIENT_INDEX || /^(?:assets|cmaps|standard_fonts|wasm)\//.test(file)) &&
!file.includes('\\') &&
!file.includes('\0') &&
file.split('/').every((part) => part !== '' && part !== '.' && part !== '..') &&
!file.endsWith('.map')
)
) {
throw failure(`invalid web file manifest at ${manifestPath}`)
}
const absent = files.filter(
(file) => !statSync(join(webRoot, file), { throwIfNoEntry: false })?.isFile()
)
if (absent.length > 0) {
throw failure(`missing unpacked web files: ${absent.join(', ')}`)
}
const indexPath = join(webRoot, WEB_CLIENT_INDEX)
if (!existsSync(indexPath)) {
throw failure(`missing unpacked ${indexPath}. ${REMEDY}`)
@@ -37,7 +63,10 @@ function assertPackagedWebClient(resourcesDir) {
throw failure(`${indexPath} references no ./ assets. ${REMEDY}`)
}
const missing = assets.filter(
(asset) => asset.startsWith('..') || !existsSync(join(webRoot, ...asset.split('/')))
(asset) =>
asset.startsWith('..') ||
!files.includes(asset) ||
!existsSync(join(webRoot, ...asset.split('/')))
)
if (missing.length > 0) {
throw failure(`${indexPath} loads files missing from ${webRoot}: ${missing.join(', ')}`)
@@ -21,8 +21,12 @@ function packagedResources({ index, assets = [], cli = true }) {
if (cli) {
mkdirSync(join(outDir, 'cli'), { recursive: true })
}
const webDir = join(outDir, 'web')
const webDir = join(outDir, 'renderer')
mkdirSync(join(webDir, 'assets'), { recursive: true })
writeFileSync(
join(webDir, '.orca-web-client-files.json'),
JSON.stringify(['web-index.html', ...assets])
)
if (index !== undefined) {
writeFileSync(join(webDir, 'web-index.html'), index)
}
@@ -66,6 +70,48 @@ describe('assertPackagedWebClient', () => {
)
})
it.each([
null,
{},
['assets/web.js'],
['web-index.html', 'index.html'],
['web-index.html', 'assets/../private'],
['web-index.html', 'assets//private'],
['web-index.html', 'assets/./private'],
['web-index.html', 'assets/a\\private'],
['web-index.html', 'assets/private.js.map'],
['web-index.html', '/assets/a'],
['web-index.html', 'assets/a\0.js'],
['web-index.html', 3]
])('rejects malformed shared web manifests: %j', (files) => {
const resourcesDir = packagedResources({
index: INDEX,
assets: ['assets/web.js', 'assets/web.css']
})
writeFileSync(
join(resourcesDir, 'app.asar.unpacked/out/renderer/.orca-web-client-files.json'),
JSON.stringify(files)
)
expect(() => assertPackagedWebClient(resourcesDir)).toThrow(/invalid web file manifest/)
})
it('fails when a transitive worker is missing even though HTML assets exist', () => {
const resourcesDir = packagedResources({
index: INDEX,
assets: ['assets/web.js', 'assets/web.css']
})
writeFileSync(
join(resourcesDir, 'app.asar.unpacked/out/renderer/.orca-web-client-files.json'),
JSON.stringify([
'web-index.html',
'assets/web.js',
'assets/web.css',
'assets/editor.worker.js'
])
)
expect(() => assertPackagedWebClient(resourcesDir)).toThrow(/editor.worker.js/)
})
it('fails when the CLI the web root resolves from is not unpacked', () => {
const resourcesDir = packagedResources({
index: INDEX,
@@ -1,7 +1,8 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http'
import { extname, isAbsolute, posix, relative, resolve } from 'node:path'
import { basename, extname, isAbsolute, posix, relative, resolve } from 'node:path'
import { readBundledWebClientFiles } from '../../../shared/bundled-web-client-manifest'
const STATIC_WEB_ALLOWED_PATHS = new Set(['/web-index.html'])
const STATIC_WEB_ALLOWED_PREFIXES = ['/assets/', '/cmaps/', '/standard_fonts/', '/wasm/']
@@ -24,13 +25,18 @@ export const answerWithoutWebClient: RequestListener = (_request, response) => {
export function createStaticWebClientHandler(staticRoot: string): RequestListener {
const resolvedRoot = resolve(staticRoot)
const files = readBundledWebClientFiles(resolvedRoot)
if (basename(resolvedRoot) === 'renderer' && !files) {
return answerWithoutWebClient
}
return (request, response) => {
void handleStaticRequest(resolvedRoot, request, response)
void handleStaticRequest(resolvedRoot, files, request, response)
}
}
async function handleStaticRequest(
staticRoot: string,
files: ReadonlySet<string> | undefined,
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
@@ -48,7 +54,7 @@ async function handleStaticRequest(
writeHttpStatus(response, 400)
return
}
if (!isAllowedStaticWebPath(pathname)) {
if (!isAllowedStaticWebPath(pathname) || (files && !files.has(pathname.slice(1)))) {
writeHttpStatus(response, 404)
return
}
@@ -0,0 +1,88 @@
import { once } from 'node:events'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { createStaticWebClientHandler } from './static-web-client-handler'
const roots: string[] = []
const servers: Server[] = []
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => {
server.closeAllConnections()
return new Promise<void>((resolve) => server.close(() => resolve()))
})
)
roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))
})
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-shared-static-'))
roots.push(root)
const renderer = join(root, 'renderer')
const files = [
'web-index.html',
'assets/shared.js',
'assets/editor.worker.js',
'cmaps/Font.bcmap',
'standard_fonts/Font.pfb',
'wasm/viewer.wasm'
]
for (const file of [
...files,
'index.html',
'popout.html',
'space.html',
'assets/desktop.js',
'assets/shared.js.map'
]) {
mkdirSync(dirname(join(renderer, file)), { recursive: true })
writeFileSync(join(renderer, file), file)
}
writeFileSync(join(renderer, '.orca-web-client-files.json'), JSON.stringify(files))
return { renderer, files }
}
async function serve(root: string) {
const server = createServer(createStaticWebClientHandler(root))
servers.push(server)
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('No address')
}
return `http://127.0.0.1:${address.port}`
}
it('serves only the web closure at root and through a reverse proxy, including worker/PDF files', async () => {
const { renderer, files } = fixture()
const endpoint = await serve(renderer)
for (const prefix of ['', '/proxy/orca']) {
for (const file of files) {
const response = await fetch(`${endpoint}${prefix}/${file}`)
expect(response.status, `${prefix}/${file}`).toBe(200)
expect(await response.text()).toBe(file)
}
for (const file of [
'popout.html',
'space.html',
'assets/desktop.js',
'assets/shared.js.map',
'.orca-web-client-files.json'
]) {
expect((await fetch(`${endpoint}${prefix}/${file}`)).status, file).toBe(404)
}
}
expect(await (await fetch(endpoint)).text()).toBe('web-index.html')
const head = await fetch(`${endpoint}/assets/shared.js`, { method: 'HEAD' })
expect(head.headers.get('content-length')).toBe(String('assets/shared.js'.length))
expect(await head.text()).toBe('')
})
it('fails closed when a renderer has no manifest or a corrupt manifest', async () => {
const { renderer } = fixture()
rmSync(join(renderer, '.orca-web-client-files.json'))
const endpoint = await serve(renderer)
expect((await fetch(`${endpoint}/assets/desktop.js`)).status).toBe(404)
writeFileSync(join(renderer, '.orca-web-client-files.json'), '{')
expect(() => createStaticWebClientHandler(renderer)).toThrow()
})
@@ -0,0 +1,70 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readBundledWebClientFiles, WEB_CLIENT_FILES_FILENAME } from './bundled-web-client-manifest'
import { findBundledWebClientRoot } from './bundled-web-client-root'
const roots: string[] = []
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-shared-web-'))
roots.push(root)
return root
}
function manifest(root: string, files: unknown) {
mkdirSync(root, { recursive: true })
writeFileSync(join(root, WEB_CLIENT_FILES_FILENAME), JSON.stringify(files))
}
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
describe('bundled web file manifest', () => {
it('retains standalone bundles and validates every shared path', () => {
const root = fixture()
expect(readBundledWebClientFiles(root)).toBeUndefined()
const files = [
'web-index.html',
'assets/index.js',
'assets/editor.worker.js',
'cmaps/Font.bcmap',
'standard_fonts/Font.pfb',
'wasm/viewer.wasm'
]
manifest(root, files)
expect([...(readBundledWebClientFiles(root) ?? [])]).toEqual(files)
})
it.each([
null,
{},
['assets/index.js'],
['web-index.html', 'index.html'],
['web-index.html', 'assets/../private'],
['web-index.html', 'assets//private'],
['web-index.html', 'assets/./private'],
['web-index.html', 'assets/a\\private'],
['web-index.html', 'assets/private.js.map'],
['web-index.html', '/assets/a'],
['web-index.html', 'assets/a\0.js'],
['web-index.html', 3]
])('rejects malformed manifests without broadening access: %j', (files) => {
const root = fixture()
manifest(root, files)
expect(() => readBundledWebClientFiles(root)).toThrow()
})
it('finds real shared renderer files from Electron and plain Node roots', () => {
const root = fixture()
const appRoot = join(root, 'app.asar')
const unpacked = `${appRoot}.unpacked`
const renderer = join(unpacked, 'out/renderer')
mkdirSync(renderer, { recursive: true })
writeFileSync(join(renderer, 'web-index.html'), 'web')
expect(findBundledWebClientRoot(appRoot)).toBeUndefined()
manifest(renderer, ['web-index.html'])
expect(findBundledWebClientRoot(appRoot)).toBe(renderer)
expect(findBundledWebClientRoot(unpacked)).toBe(renderer)
expect(findBundledWebClientRoot(join(unpacked, 'out/main'))).toBe(renderer)
const web = join(unpacked, 'out/web')
mkdirSync(web)
writeFileSync(join(web, 'web-index.html'), 'standalone')
expect(findBundledWebClientRoot(unpacked)).toBe(web)
})
})
+29
View File
@@ -0,0 +1,29 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
export const WEB_CLIENT_FILES_FILENAME = '.orca-web-client-files.json'
/** Limits HTTP access when desktop and browser assets share one installed directory. */
export function readBundledWebClientFiles(root: string): ReadonlySet<string> | undefined {
const manifestPath = join(root, WEB_CLIENT_FILES_FILENAME)
if (!existsSync(manifestPath)) {
return undefined
}
const files: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (
!Array.isArray(files) ||
!files.every(
(file): file is string =>
typeof file === 'string' &&
(file === 'web-index.html' || /^(?:assets|cmaps|standard_fonts|wasm)\//.test(file)) &&
!file.includes('\\') &&
!file.includes('\0') &&
file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') &&
!file.endsWith('.map')
) ||
!files.includes('web-index.html')
) {
throw new Error('Invalid bundled web client file manifest')
}
return new Set(files)
}
+15 -1
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { WEB_CLIENT_FILES_FILENAME } from './bundled-web-client-manifest'
export const WEB_CLIENT_INDEX_FILENAME = 'web-index.html'
@@ -13,5 +14,18 @@ export function findBundledWebClientRoot(appPath: string): string | undefined {
// Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle.
join(appPath, '..', 'web')
]
return roots.find((root) => existsSync(join(root, WEB_CLIENT_INDEX_FILENAME)))
const standalone = roots.find((root) => existsSync(join(root, WEB_CLIENT_INDEX_FILENAME)))
if (standalone) {
return standalone
}
const sharedAppPath = appPath.endsWith('.asar') ? `${appPath}.unpacked` : appPath
const sharedRoots = [
join(sharedAppPath, 'out', 'renderer'),
join(sharedAppPath, '..', 'renderer')
]
return sharedRoots.find(
(root) =>
existsSync(join(root, WEB_CLIENT_INDEX_FILENAME)) &&
existsSync(join(root, WEB_CLIENT_FILES_FILENAME))
)
}