diff --git a/packages/plugin/webpack/package.json b/packages/plugin/webpack/package.json index 7f79f7f8cc..2275217eb7 100644 --- a/packages/plugin/webpack/package.json +++ b/packages/plugin/webpack/package.json @@ -15,6 +15,7 @@ "@malept/cross-spawn-promise": "^2.0.0", "@types/node": "^18.0.3", "chai": "^4.3.3", + "electron": "^29.0.1", "mocha": "^9.0.1", "sinon": "^13.0.1", "which": "^2.0.2", diff --git a/packages/plugin/webpack/src/Config.ts b/packages/plugin/webpack/src/Config.ts index 2a201af825..30349d9d2d 100644 --- a/packages/plugin/webpack/src/Config.ts +++ b/packages/plugin/webpack/src/Config.ts @@ -130,6 +130,45 @@ export interface WebpackPluginConfig { * The webpack config for your main process */ mainConfig: WebpackConfiguration | string; + /** + * Configuration for a custom protocol used to load assets from disk in packaged apps. + * This has no effect on development apps. + */ + customProtocolForPackagedAssets?: { + /** + * Whether to use a custom protocol, if false file:// will be used + * file:// is considered unsafe so you should opt in to this if you + * can. It will become the default in an upcoming major version + * of Electron Forge + */ + enabled: boolean; + /** + * Custom protocol name, defaults to "app-internal-static", + */ + protocolName?: string; + /** + * If you are going to register the protocol handler yourself for + * some reason you can set this to false explicitly to avoid forge + * injecting the protocol initialization code. + */ + autoRegisterProtocol?: boolean; + /** + * Protocol privileges, maps to the [`CustomScheme.privileges`](https://www.electronjs.org/docs/latest/api/structures/custom-scheme) + * object from the core Electron API. + * + * Defaults to `standard | secure | allowServiceWorkers | supportFetchAPI | corsEnabled | codeCache` + */ + privileges?: { + standard?: boolean; + secure?: boolean; + bypassCSP?: boolean; + allowServiceWorkers?: boolean; + supportFetchAPI?: boolean; + corsEnabled?: boolean; + stream?: boolean; + codeCache?: boolean; + }; + }; /** * Instructs webpack to emit a JSON file containing statistics about modules, the dependency * graph, and various other build information for the main process. This file is located in diff --git a/packages/plugin/webpack/src/WebpackConfig.ts b/packages/plugin/webpack/src/WebpackConfig.ts index f22c6b88ae..16ab779d18 100644 --- a/packages/plugin/webpack/src/WebpackConfig.ts +++ b/packages/plugin/webpack/src/WebpackConfig.ts @@ -21,6 +21,8 @@ type WebpackMode = 'production' | 'development'; const d = debug('electron-forge:plugin:webpack:webpackconfig'); +const DEFAULT_CUSTOM_PROTOCOL_NAME = 'app-internal-static'; + export type ConfigurationFactory = ( env: string | Record | unknown, args: Record @@ -112,7 +114,7 @@ export default class WebpackConfigGenerator { rendererEntryPoint(entryPoint: WebpackPluginEntryPoint, basename: string): string { if (this.isProd) { - return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``; + return this.getInPackageURLForRenderer(entryPoint, basename); } const baseUrl = `http://localhost:${this.port}/${entryPoint.name}`; if (basename !== 'index.html') { @@ -165,9 +167,44 @@ export default class WebpackConfigGenerator { } } + if (this.isProd && this.pluginConfig.customProtocolForPackagedAssets?.enabled) { + defines['__ELECTRON_FORGE_INTERNAL_PROTOCOL_CONFIGURATION__'] = JSON.stringify({ + protocolName: DEFAULT_CUSTOM_PROTOCOL_NAME, + autoRegisterProtocol: true, + privileges: { + standard: true, + secure: true, + allowServiceWorkers: true, + supportFetchAPI: true, + corsEnabled: true, + codeCache: true, + }, + ...this.pluginConfig.customProtocolForPackagedAssets, + }); + } + return defines; } + private get shouldInjectProtocolLoader() { + return ( + this.isProd && + this.pluginConfig.customProtocolForPackagedAssets?.enabled && + this.pluginConfig.customProtocolForPackagedAssets?.autoRegisterProtocol !== false + ); + } + + private getInPackageURLForRenderer(entryPoint: WebpackPluginEntryPoint, basename: string) { + const shouldUseCustomProtocol = this.pluginConfig.customProtocolForPackagedAssets?.enabled; + if (!shouldUseCustomProtocol) { + return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``; + } + let customProtocol = + this.isProd && this.pluginConfig.customProtocolForPackagedAssets?.enabled && this.pluginConfig.customProtocolForPackagedAssets?.protocolName; + if (!customProtocol) customProtocol = DEFAULT_CUSTOM_PROTOCOL_NAME; + return `${customProtocol}://renderer/${entryPoint.name}/${basename}`; + } + async getMainConfig(): Promise { const mainConfig = await this.resolveConfig(this.pluginConfig.mainConfig); @@ -177,7 +214,11 @@ export default class WebpackConfigGenerator { const fix = (item: EntryType): EntryType => { if (typeof item === 'string') return (fix([item]) as string[])[0]; if (Array.isArray(item)) { - return item.map((val) => (val.startsWith('./') ? path.resolve(this.projectDir, val) : val)); + const injectedCode: string[] = []; + if (this.shouldInjectProtocolLoader) { + injectedCode.push(path.resolve(__dirname, 'inject', 'protocol-loader.js')); + } + return injectedCode.concat(item.map((val) => (val.startsWith('./') ? path.resolve(this.projectDir, val) : val))); } const ret: Record = {}; for (const key of Object.keys(item)) { diff --git a/packages/plugin/webpack/src/inject/protocol-loader.ts b/packages/plugin/webpack/src/inject/protocol-loader.ts new file mode 100644 index 0000000000..a82a46a203 --- /dev/null +++ b/packages/plugin/webpack/src/inject/protocol-loader.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; + +// eslint-disable-next-line node/no-unpublished-import +import { protocol } from 'electron'; + +import type { WebpackPluginConfig } from '../Config'; + +type InternalConfig = Required['customProtocolForPackagedAssets']>; +declare const __ELECTRON_FORGE_INTERNAL_PROTOCOL_CONFIGURATION__: InternalConfig; + +const config: InternalConfig = __ELECTRON_FORGE_INTERNAL_PROTOCOL_CONFIGURATION__ as any; + +const appRoot = path.join(__dirname, '..'); +const rendererRoot = path.join(appRoot, 'renderer'); + +const STATUS_CODE_BAD_REQUEST = 400; +const STATUS_CODE_FORBIDDEN = 403; +const STATUS_CODE_INTERNAL_SERVER_ERROR = 500; + +protocol.registerFileProtocol(config.protocolName, (request, cb) => { + try { + const requestUrl = new URL(decodeURI(request.url)); + + if (requestUrl.protocol !== `${config.protocolName}:`) { + return cb({ statusCode: STATUS_CODE_BAD_REQUEST }); + } + + if (request.url.includes('..')) { + return cb({ statusCode: STATUS_CODE_FORBIDDEN }); + } + + if (requestUrl.host !== 'renderer') { + return cb({ statusCode: STATUS_CODE_BAD_REQUEST }); + } + + if (!requestUrl.pathname || requestUrl.pathname === '/') { + return cb({ statusCode: STATUS_CODE_BAD_REQUEST }); + } + + // Resolve relative to appRoot + const filePath = path.join(appRoot, requestUrl.pathname); + // But ensure we are within the rendererRoot + const relative = path.relative(rendererRoot, filePath); + const isSafe = relative && !relative.startsWith('..') && !path.isAbsolute(relative); + + if (!isSafe) { + return cb({ statusCode: STATUS_CODE_BAD_REQUEST }); + } + + return cb({ path: filePath }); + } catch (error) { + const errorMessage = `Unexpected error in ${config.protocolName}:// protocol handler.`; + console.error(errorMessage, error); + return cb({ statusCode: STATUS_CODE_INTERNAL_SERVER_ERROR }); + } +}); + +protocol.registerSchemesAsPrivileged([ + { + scheme: config.protocolName, + privileges: config.privileges, + }, +]); diff --git a/yarn.lock b/yarn.lock index 2f9ac62650..9430f095d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -987,6 +987,21 @@ fs-extra "^9.0.1" minimist "^1.2.5" +"@electron/get@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@electron/get/-/get-2.0.3.tgz#fba552683d387aebd9f3fcadbcafc8e12ee4f960" + integrity sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ== + dependencies: + debug "^4.1.1" + env-paths "^2.2.0" + fs-extra "^8.1.0" + got "^11.8.5" + progress "^2.0.3" + semver "^6.2.0" + sumchecker "^3.0.1" + optionalDependencies: + global-agent "^3.0.0" + "@electron/get@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@electron/get/-/get-3.0.0.tgz#2b0c794b98902d0bc5218546872c1379bef68aa2" @@ -3130,6 +3145,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^20.9.0": + version "20.11.20" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.20.tgz#f0a2aee575215149a62784210ad88b3a34843659" + integrity sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -5545,6 +5567,15 @@ electron-wix-msi@^5.0.0: optionalDependencies: "@bitdisaster/exe-icon-extractor" "^1.0.10" +electron@^29.0.1: + version "29.0.1" + resolved "https://registry.yarnpkg.com/electron/-/electron-29.0.1.tgz#936c0623a1bbf272dea423305f074de6ac016967" + integrity sha512-hsQr9clm8NCAMv4uhHlXThHn52UAgrHgyz3ubBAxZIPuUcpKVDtg4HPmx4hbmHIbYICI5OyLN3Ztp7rS+Dn4Lw== + dependencies: + "@electron/get" "^2.0.0" + "@types/node" "^20.9.0" + extract-zip "^2.0.1" + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -6458,7 +6489,7 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" -extract-zip@^2.0.0: +extract-zip@^2.0.0, extract-zip@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==