From 937d2d38064ce1bd1c5c9cb7fd3d5be946e4e9a5 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 19 Oct 2026 22:31:59 -0700
Subject: [PATCH] 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
---
config/electron-builder.config.cjs | 6 +-
.../scripts/electron-builder-config.test.mjs | 9 +-
...lectron-builder-runtime-resources.test.mjs | 6 +-
.../packaged-cloud-source-exclusion.test.mjs | 2 +-
.../scripts/project-renderer-web-client.mjs | 5 ++
.../project-renderer-web-client.test.mjs | 17 ++++
.../scripts/release-javascript-artifact.mjs | 1 +
.../release-javascript-artifact.test.mjs | 1 +
.../scripts/verify-packaged-serve-payload.cjs | 35 +++++++-
.../verify-packaged-serve-payload.test.mjs | 48 +++++++++-
.../runtime/rpc/static-web-client-handler.ts | 12 ++-
.../static-web-client-shared-assets.test.ts | 88 +++++++++++++++++++
.../bundled-web-client-manifest.test.ts | 70 +++++++++++++++
src/shared/bundled-web-client-manifest.ts | 29 ++++++
src/shared/bundled-web-client-root.ts | 16 +++-
15 files changed, 331 insertions(+), 14 deletions(-)
create mode 100644 src/main/runtime/rpc/static-web-client-shared-assets.test.ts
create mode 100644 src/shared/bundled-web-client-manifest.test.ts
create mode 100644 src/shared/bundled-web-client-manifest.ts
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 11e8df74bb9..ff5c20876f1 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -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/**',
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index d7a6e0235a3..4c76725621a 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -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'
]) {
diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs
index 021711ab574..69f5acf2b4c 100644
--- a/config/scripts/electron-builder-runtime-resources.test.mjs
+++ b/config/scripts/electron-builder-runtime-resources.test.mjs
@@ -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'), '')
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) {
diff --git a/config/scripts/packaged-cloud-source-exclusion.test.mjs b/config/scripts/packaged-cloud-source-exclusion.test.mjs
index 53e3fe8dd09..e53b4be4954 100644
--- a/config/scripts/packaged-cloud-source-exclusion.test.mjs
+++ b/config/scripts/packaged-cloud-source-exclusion.test.mjs
@@ -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 {
diff --git a/config/scripts/project-renderer-web-client.mjs b/config/scripts/project-renderer-web-client.mjs
index a3df2d9b1b5..50fe78376ad 100644
--- a/config/scripts/project-renderer-web-client.mjs
+++ b/config/scripts/project-renderer-web-client.mjs
@@ -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 })
}
diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs
index 0b7525fb378..55ca40ce6a7 100644
--- a/config/scripts/project-renderer-web-client.test.mjs
+++ b/config/scripts/project-renderer-web-client.test.mjs
@@ -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')
})
diff --git a/config/scripts/release-javascript-artifact.mjs b/config/scripts/release-javascript-artifact.mjs
index 5a7f610594b..a04cf2445ef 100644
--- a/config/scripts/release-javascript-artifact.mjs
+++ b/config/scripts/release-javascript-artifact.mjs
@@ -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'
diff --git a/config/scripts/release-javascript-artifact.test.mjs b/config/scripts/release-javascript-artifact.test.mjs
index d0faff99f29..c98efdb5456 100644
--- a/config/scripts/release-javascript-artifact.test.mjs
+++ b/config/scripts/release-javascript-artifact.test.mjs
@@ -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',
diff --git a/config/scripts/verify-packaged-serve-payload.cjs b/config/scripts/verify-packaged-serve-payload.cjs
index a60ec33a92a..f9f361e9715 100644
--- a/config/scripts/verify-packaged-serve-payload.cjs
+++ b/config/scripts/verify-packaged-serve-payload.cjs
@@ -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(', ')}`)
diff --git a/config/scripts/verify-packaged-serve-payload.test.mjs b/config/scripts/verify-packaged-serve-payload.test.mjs
index 37bfe660df5..b683174df34 100644
--- a/config/scripts/verify-packaged-serve-payload.test.mjs
+++ b/config/scripts/verify-packaged-serve-payload.test.mjs
@@ -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,
diff --git a/src/main/runtime/rpc/static-web-client-handler.ts b/src/main/runtime/rpc/static-web-client-handler.ts
index c0b3181bdad..cd0b700f880 100644
--- a/src/main/runtime/rpc/static-web-client-handler.ts
+++ b/src/main/runtime/rpc/static-web-client-handler.ts
@@ -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 | undefined,
request: IncomingMessage,
response: ServerResponse
): Promise {
@@ -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
}
diff --git a/src/main/runtime/rpc/static-web-client-shared-assets.test.ts b/src/main/runtime/rpc/static-web-client-shared-assets.test.ts
new file mode 100644
index 00000000000..710e895c6da
--- /dev/null
+++ b/src/main/runtime/rpc/static-web-client-shared-assets.test.ts
@@ -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((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()
+})
diff --git a/src/shared/bundled-web-client-manifest.test.ts b/src/shared/bundled-web-client-manifest.test.ts
new file mode 100644
index 00000000000..893af84eb75
--- /dev/null
+++ b/src/shared/bundled-web-client-manifest.test.ts
@@ -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)
+ })
+})
diff --git a/src/shared/bundled-web-client-manifest.ts b/src/shared/bundled-web-client-manifest.ts
new file mode 100644
index 00000000000..e174fa0038a
--- /dev/null
+++ b/src/shared/bundled-web-client-manifest.ts
@@ -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 | 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)
+}
diff --git a/src/shared/bundled-web-client-root.ts b/src/shared/bundled-web-client-root.ts
index 959e8b5b6ac..364bc822aad 100644
--- a/src/shared/bundled-web-client-root.ts
+++ b/src/shared/bundled-web-client-root.ts
@@ -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))
+ )
}