fix(shell): reload the packaged renderer back onto its in-app route (#1622)

The packaged renderer is index.html over file:// with path routing, so
after in-app navigation the document URL names a path with no file behind
it. A main-process reload (macOS View > Reload, DevTools) failed with
ERR_FILE_NOT_FOUND and stranded the window on Chromium's error page; a
renderer-initiated reload (the settings unsaved-changes guard's confirmed
location.reload()) was cancelled by the will-navigate trust check and
silently did nothing.

Both legs now re-load the packaged index with the route in a restoreRoute
query parameter, which main.ts restores with history.replaceState before
Angular bootstraps. The did-fail-load recovery is deferred to the error
page's dom-ready: a load issued from inside the failure event yields a
document that never receives animation frames and never paints.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-19 08:43:43 +02:00
1 parent fa8ce26991
commit 6f987d79d8
14 files changed
+1113 -12

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
export * from './lib/recording-program-overlap.util';
export * from './lib/renderer-reload-route.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-auth-failure.util';
@@ -0,0 +1,85 @@
import {
RENDERER_RESTORE_ROUTE_QUERY_PARAM,
resolveRestoredRendererRoute,
} from './renderer-reload-route.util';
const rendererRoot = 'file:///Applications/IPTVnator.app/Contents/web/';
const packagedIndex = `${rendererRoot}index.html`;
function reloadedIndex(route: string): string {
const url = new URL(packagedIndex);
url.searchParams.set(RENDERER_RESTORE_ROUTE_QUERY_PARAM, route);
return url.href;
}
describe('resolveRestoredRendererRoute', () => {
it('leaves a document without a restore request alone', () => {
expect(resolveRestoredRendererRoute(packagedIndex, packagedIndex)).toBe(
null
);
expect(
resolveRestoredRendererRoute(
`${packagedIndex}?other=1`,
packagedIndex
)
).toBe(null);
});
it('restores a route relative to the renderer directory', () => {
expect(
resolveRestoredRendererRoute(
reloadedIndex('workspace/sources'),
packagedIndex
)
).toBe(`${rendererRoot}workspace/sources`);
});
it('keeps the restored route query and fragment', () => {
expect(
resolveRestoredRendererRoute(
reloadedIndex('workspace/xtreams/3/search?q=dune#top'),
packagedIndex
)
).toBe(`${rendererRoot}workspace/xtreams/3/search?q=dune#top`);
});
it('resolves against the base URI, not the document URL', () => {
// The packaged <base href="./"> resolves to the renderer directory
// even when the document itself sits deeper.
expect(
resolveRestoredRendererRoute(
`${rendererRoot}workspace/sources?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fdashboard`,
rendererRoot
)
).toBe(`${rendererRoot}workspace/dashboard`);
});
it('works for an http origin with a root base href', () => {
expect(
resolveRestoredRendererRoute(
`http://localhost:4200/?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fsettings%2Fplayback`,
'http://localhost:4200/'
)
).toBe('http://localhost:4200/workspace/settings/playback');
});
it.each([
['an absolute URL', 'https://example.com/phish'],
['another scheme', 'javascript:alert(1)'],
['a directory escape', '../../etc/passwd'],
['a root-absolute path outside the renderer', '/etc/passwd'],
['a scheme-relative URL', '//example.com/'],
['the renderer directory itself', './'],
['an empty route', ''],
])('drops %s and only removes the parameter', (_label, route) => {
expect(
resolveRestoredRendererRoute(reloadedIndex(route), packagedIndex)
).toBe(packagedIndex);
});
it('returns null for an unparsable document URL', () => {
expect(resolveRestoredRendererRoute('not a url', packagedIndex)).toBe(
null
);
});
});
@@ -0,0 +1,72 @@
/**
* Route hand-off for a reloaded packaged renderer.
*
* The packaged renderer is loaded from `dist/apps/web/index.html` over
* `file://` and Angular uses PATH routing, so after in-app navigation the
* document URL is `file:///…/web/workspace/sources` — a path with no file
* behind it. A reload (the macOS View › Reload menu, DevTools, the settings
* unsaved-changes guard's confirmed reload) therefore fails with
* `ERR_FILE_NOT_FOUND` and strands the window on Chromium's error page.
*
* The Electron main process recovers such a failure by loading `index.html`
* again with the failed URL's route (its path relative to the renderer root,
* plus query and fragment) carried in this query parameter. The renderer
* consumes it before Angular bootstraps: `resolveRestoredRendererRoute`
* turns the current document URL into the in-app URL the router should
* start from, and `main.ts` installs it with `history.replaceState`, so the
* router's initial navigation lands on the route the user was on.
*/
/** Query parameter carrying the route to restore on the reloaded index. */
export const RENDERER_RESTORE_ROUTE_QUERY_PARAM = 'restoreRoute';
/**
* The URL to present instead of `currentHref` before the router's initial
* navigation, or `null` when the document carries no restore request.
*
* `baseUri` is `document.baseURI`: the packaged build's `<base href="./">`
* resolves to the renderer's directory, which is also what Angular strips
* from `location.pathname` to obtain the route. A route that would leave
* that directory — an absolute URL, another scheme, a `..` escape — is
* dropped and only the parameter is removed, so the app boots at its
* default route rather than following an arbitrary target.
*/
export function resolveRestoredRendererRoute(
currentHref: string,
baseUri: string
): string | null {
let current: URL;
let baseDirectory: URL;
try {
current = new URL(currentHref);
baseDirectory = new URL('./', baseUri);
} catch {
return null;
}
const route = current.searchParams.get(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
if (route === null) {
return null;
}
current.searchParams.delete(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
const stripped = current.href;
let target: URL;
try {
target = new URL(route, baseDirectory);
} catch {
return stripped;
}
const staysInsideRenderer =
target.protocol === baseDirectory.protocol &&
target.host === baseDirectory.host &&
target.pathname.startsWith(baseDirectory.pathname) &&
target.pathname !== baseDirectory.pathname;
return staysInsideRenderer ? target.href : stripped;
}