Skip to content

Commit 8d8a984

Browse files
committed
fix: load shell via openclaw-shell:// instead of file:// (blank window)
Chromium/Electron often fail to run Vite ES-module bundles from file:// even without crossorigin. Register a privileged custom protocol mapping to the unpacked renderer directory, load index via openclaw-shell://renderer/. Extend gateway CSP frame-ancestors for this origin. Release 0.2.7. Made-with: Cursor
1 parent 7e39fdd commit 8d8a984

7 files changed

Lines changed: 148 additions & 49 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openclaw-desktop",
3-
"version": "0.2.6",
3+
"version": "0.2.7",
44
"description": "Community-maintained Windows desktop app and installer for OpenClaw.",
55
"type": "module",
66
"main": "out/main/index.js",

resources/bundle-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"shellVersion": "0.2.6",
2+
"shellVersion": "0.2.7",
33
"bundledOpenClawVersion": "2026.3.23-1"
44
}

scripts/smoke/gateway-response-headers-smoke.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ function testFrameAncestorsRelaxation(): void {
2424
const replaced = relaxGatewayFrameAncestors("default-src 'self'; frame-ancestors 'none'; script-src 'self'")
2525
assert.match(replaced, /default-src 'self';/)
2626
assert.match(replaced, /script-src 'self'/)
27-
assert.match(replaced, /frame-ancestors 'self' file: http:\/\/localhost:\*/)
27+
assert.match(replaced, /frame-ancestors 'self' file:.*http:\/\/localhost:\*/)
2828

2929
const appended = relaxGatewayFrameAncestors("default-src 'self'")
3030
assert.equal(
3131
appended,
32-
"default-src 'self'; frame-ancestors 'self' file: http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*",
32+
"default-src 'self'; frame-ancestors 'self' file: openclaw-shell://renderer http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*",
3333
)
3434

3535
const fromEmpty = relaxGatewayFrameAncestors('')
@@ -47,7 +47,7 @@ function testHeaderPatchForLoopbackResponse(): void {
4747
assert.equal((patched as Record<string, unknown>)['X-Frame-Options'], undefined)
4848
assert.equal(
4949
patched?.['Content-Security-Policy']?.[0],
50-
"default-src 'self'; frame-ancestors 'self' file: http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*",
50+
"default-src 'self'; frame-ancestors 'self' file: openclaw-shell://renderer http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*",
5151
)
5252
assert.deepEqual(patched?.Server, ['openclaw'])
5353
}

src/main/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import { patchGatewayResponseHeaders } from './security/gateway-response-headers
2727
import { rewriteGatewayRequestUrlWithToken } from './security/gateway-request-auth.js'
2828
import { listPendingFeishuPairing } from './pairing/index.js'
2929
import { resolveTrayLocale, getFeishuPairingNotificationStrings, formatFeishuPairingBody } from './tray/tray-i18n.js'
30+
import { registerShellFileProtocol, registerShellPrivileges } from './shell-protocol.js'
31+
32+
/** Must run before app 'ready' so the shell can use openclaw-shell:// instead of file:// */
33+
registerShellPrivileges()
3034

3135
process.on('uncaughtException', (error) => {
3236
if ((error as NodeJS.ErrnoException).code === 'EPIPE') return
@@ -117,6 +121,7 @@ app.whenReady().then(() => {
117121

118122
Menu.setApplicationMenu(null) // Remove View, File, etc. menu bar
119123
initShellLog()
124+
registerShellFileProtocol()
120125

121126
// Allow Control UI to be embedded in our Shell iframe: OpenClaw sets X-Frame-Options: DENY
122127
// and frame-ancestors 'none', which would block the iframe. We intercept and relax these

src/main/security/gateway-response-headers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export const LOOPBACK_GATEWAY_HOSTS = new Set(['127.0.0.1', 'localhost', '::1'])
22

33
export const RELAXED_GATEWAY_FRAME_ANCESTORS =
4-
"frame-ancestors 'self' file: http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*"
4+
"frame-ancestors 'self' file: openclaw-shell://renderer http://localhost:* http://127.0.0.1:* http://[::1]:* https://localhost:* https://127.0.0.1:* https://[::1]:*"
55

66
export type GatewayResponseHeadersInput = Record<string, string[] | string | undefined>
77
export type GatewayResponseHeaders = Record<string, string[]>

src/main/shell-protocol.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { app, protocol } from 'electron'
2+
import fs from 'node:fs'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
7+
8+
/** Custom scheme so the shell is not loaded via file:// (module/CORS/CSP edge cases on Windows). */
9+
export const SHELL_CUSTOM_SCHEME = 'openclaw-shell' as const
10+
export const SHELL_CUSTOM_HOST = 'renderer' as const
11+
12+
let rendererRootCache: string | null = null
13+
14+
function getShellRendererRootDir(): string {
15+
if (app.isPackaged) {
16+
const unpacked = path.join(process.resourcesPath, 'app.asar.unpacked', 'out', 'renderer')
17+
if (fs.existsSync(unpacked)) return unpacked
18+
return path.join(app.getAppPath(), 'out', 'renderer')
19+
}
20+
return path.join(__dirname, '../renderer')
21+
}
22+
23+
function getShellRendererRootCached(): string {
24+
if (!rendererRootCache) {
25+
rendererRootCache = path.resolve(getShellRendererRootDir())
26+
}
27+
return rendererRootCache
28+
}
29+
30+
/** Call before app 'ready' (Electron requirement). */
31+
export function registerShellPrivileges(): void {
32+
protocol.registerSchemesAsPrivileged([
33+
{
34+
scheme: SHELL_CUSTOM_SCHEME,
35+
privileges: {
36+
standard: true,
37+
secure: true,
38+
supportFetchAPI: true,
39+
corsEnabled: true,
40+
stream: true,
41+
},
42+
},
43+
])
44+
}
45+
46+
export function getShellIndexPageUrl(hash?: string): string {
47+
const base = `${SHELL_CUSTOM_SCHEME}://${SHELL_CUSTOM_HOST}/index.html`
48+
if (!hash) return base
49+
const h = hash.startsWith('#') ? hash : `#${hash}`
50+
return `${base}${h}`
51+
}
52+
53+
export function isShellCustomProtocolUrl(rawUrl: string): boolean {
54+
try {
55+
const u = new URL(rawUrl)
56+
return u.protocol === `${SHELL_CUSTOM_SCHEME}:` && u.hostname.toLowerCase() === SHELL_CUSTOM_HOST
57+
} catch {
58+
return false
59+
}
60+
}
61+
62+
/** Register after app 'ready', before creating BrowserWindows that load the shell. */
63+
export function registerShellFileProtocol(): void {
64+
protocol.registerFileProtocol(SHELL_CUSTOM_SCHEME, (request, callback) => {
65+
try {
66+
const parsed = new URL(request.url)
67+
if (parsed.protocol !== `${SHELL_CUSTOM_SCHEME}:`) {
68+
callback({ error: -2 })
69+
return
70+
}
71+
if (parsed.hostname.toLowerCase() !== SHELL_CUSTOM_HOST) {
72+
callback({ error: -10 })
73+
return
74+
}
75+
let pathname = decodeURIComponent(parsed.pathname)
76+
if (pathname === '/' || pathname === '') {
77+
pathname = '/index.html'
78+
}
79+
const relative = pathname.replace(/^\/+/, '')
80+
if (relative.includes('..')) {
81+
callback({ error: -10 })
82+
return
83+
}
84+
const root = getShellRendererRootCached()
85+
const filePath = path.resolve(path.join(root, relative))
86+
const relToRoot = path.relative(root, filePath)
87+
if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
88+
callback({ error: -10 })
89+
return
90+
}
91+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
92+
callback({ error: -6 })
93+
return
94+
}
95+
callback({ path: filePath })
96+
} catch {
97+
callback({ error: -2 })
98+
}
99+
})
100+
}

src/main/window/manager.ts

Lines changed: 37 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { app, BrowserWindow, shell, nativeImage } from 'electron'
22
import path from 'node:path'
33
import fs from 'node:fs'
4-
import { fileURLToPath, pathToFileURL } from 'node:url'
4+
import { fileURLToPath } from 'node:url'
55
import type { ShellConfig } from '../../shared/types.js'
66
import { getLocalizedShellWindowTitle, normalizeToShellLocale } from '../../shared/shell-locale.js'
77
import { logError, logInfo, logWarn } from '../utils/logger.js'
8+
import { getShellIndexPageUrl, isShellCustomProtocolUrl } from '../shell-protocol.js'
89

910
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1011

@@ -111,6 +112,10 @@ function isAllowedNavigation(url: string, port: number): boolean {
111112
return true
112113
}
113114

115+
if (isShellCustomProtocolUrl(url)) {
116+
return true
117+
}
118+
114119
if (isControlUIUrl(url, port)) {
115120
return true
116121
}
@@ -191,8 +196,7 @@ export class WindowManager {
191196
if (!app.isPackaged) {
192197
window.once('ready-to-show', showWhenReady)
193198
}
194-
// When packaged: do NOT show on ready-to-show (would show bootstrap/black screen).
195-
// Show only after the actual renderer (index.html) has loaded.
199+
// When packaged: show only after shell URL has finished loading (see loadURL().then).
196200

197201
let loadErrorShown = false
198202
const showLoadError = (title: string, detail: string) => {
@@ -217,49 +221,35 @@ export class WindowManager {
217221

218222
if (process.env.ELECTRON_RENDERER_URL) {
219223
void window.loadURL(process.env.ELECTRON_RENDERER_URL).then(openDevToolsIfRequested)
220-
} else if (app.isPackaged) {
224+
} else {
221225
const rendererPath = getRendererIndexPath()
222-
const rendererCandidates = getPackagedRendererCandidates()
223-
logInfo(
224-
`[OpenClaw] Packaged: rendererPath=${rendererPath} candidates=${JSON.stringify(
225-
rendererCandidates,
226-
)} exists=${JSON.stringify(rendererCandidates.map((p) => fs.existsSync(p)))}`
227-
)
228-
const bootstrapHtml =
229-
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(initialTitle)}</title></head><body style="margin:0;background:transparent;"></body></html>`
226+
const shellUrl = getShellIndexPageUrl()
227+
if (app.isPackaged) {
228+
const rendererCandidates = getPackagedRendererCandidates()
229+
logInfo(
230+
`[OpenClaw] Packaged: shellUrl=${shellUrl} rendererPath=${rendererPath} candidates=${JSON.stringify(
231+
rendererCandidates,
232+
)} exists=${JSON.stringify(rendererCandidates.map((p) => fs.existsSync(p)))}`
233+
)
234+
} else {
235+
logInfo(`[OpenClaw] Dev build: shellUrl=${shellUrl} rendererPath=${rendererPath}`)
236+
}
230237
void window
231-
.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(bootstrapHtml))
238+
.loadURL(shellUrl)
232239
.then(() => {
233240
openDevToolsIfRequested()
234-
if (window.isDestroyed()) return
235-
void window
236-
.loadFile(rendererPath)
237-
.then(() => {
238-
if (!window.isDestroyed()) showWhenReady()
239-
})
240-
.catch((err) => {
241-
logError(`[OpenClaw] Failed to load renderer: ${rendererPath} ${(err instanceof Error ? err.message : String(err))}`)
242-
showLoadError(
243-
'Renderer load failed',
244-
`Path: ${rendererPath}\n\nError: ${err instanceof Error ? err.message : String(err)}\n\n` +
245-
`Check that dist\\win-unpacked\\resources\\app.asar.unpacked\\out\\renderer or resources\\app.asar\\out\\renderer exists.`,
246-
)
247-
if (!window.isDestroyed()) showWhenReady()
248-
})
249-
})
250-
.catch(() => {
251-
showLoadError('Bootstrap failed', 'Unable to load bootstrap page')
252241
if (!window.isDestroyed()) showWhenReady()
253242
})
254-
} else {
255-
const rendererPath = getRendererIndexPath()
256-
void window
257-
.loadFile(rendererPath)
258-
.then(openDevToolsIfRequested)
259243
.catch((err) => {
260-
logError(`[OpenClaw] Failed to load renderer: ${rendererPath} ${(err instanceof Error ? err.message : String(err))}`)
261-
const msg = err instanceof Error ? err.message : String(err)
262-
showLoadError('Renderer load failed', `Path: ${rendererPath}\nError: ${msg}`)
244+
logError(
245+
`[OpenClaw] Failed to load shell URL: ${shellUrl} ${err instanceof Error ? err.message : String(err)}`,
246+
)
247+
showLoadError(
248+
'Renderer load failed',
249+
`URL: ${shellUrl}\nPath: ${rendererPath}\n\nError: ${err instanceof Error ? err.message : String(err)}\n\n` +
250+
`Check that out\\renderer (or app.asar.unpacked\\out\\renderer) contains index.html and assets.`,
251+
)
252+
if (!window.isDestroyed()) showWhenReady()
263253
})
264254
}
265255

@@ -289,7 +279,12 @@ export class WindowManager {
289279
return
290280
}
291281

292-
if (validatedURL && (validatedURL.startsWith('file:') || validatedURL.startsWith('data:'))) {
282+
if (
283+
validatedURL &&
284+
(validatedURL.startsWith('file:') ||
285+
validatedURL.startsWith('data:') ||
286+
isShellCustomProtocolUrl(validatedURL))
287+
) {
293288
const title = 'Page load failed (did-fail-load)'
294289
const detail = `code: ${errorCode}\ndescription: ${errorDescription}\nurl: ${url}`
295290
showLoadError(title, detail)
@@ -328,6 +323,7 @@ export class WindowManager {
328323
const currentUrl = window.webContents.getURL()
329324
const canPatchHash =
330325
(currentUrl.startsWith('file:') && !currentUrl.startsWith('data:')) ||
326+
isShellCustomProtocolUrl(currentUrl) ||
331327
(!!process.env.ELECTRON_RENDERER_URL && currentUrl.startsWith('http'))
332328

333329
if (canPatchHash) {
@@ -350,9 +346,7 @@ export class WindowManager {
350346
void window.loadURL(`${base}${safeHash}`)
351347
return
352348
}
353-
const rendererPath = getRendererIndexPath()
354-
const fileUrl = pathToFileURL(rendererPath).href + safeHash
355-
void window.loadURL(fileUrl)
349+
void window.loadURL(getShellIndexPageUrl(safeHash))
356350
}
357351

358352
showErrorPage(title: string, detail: string): void {

0 commit comments

Comments
 (0)