diff --git a/apps/desktop/renderer.html b/apps/desktop/renderer.html
index 555db588a..9e64e557f 100644
--- a/apps/desktop/renderer.html
+++ b/apps/desktop/renderer.html
@@ -4,7 +4,7 @@
diff --git a/apps/desktop/src/ipc-lifecycle.test.ts b/apps/desktop/src/ipc-lifecycle.test.ts
index 9cda8eeba..2cf203860 100644
--- a/apps/desktop/src/ipc-lifecycle.test.ts
+++ b/apps/desktop/src/ipc-lifecycle.test.ts
@@ -142,6 +142,63 @@ describe('desktop IPC shutdown gate', () => {
]);
});
+ it('schedules a replacement document before publishing a newly admitted renderer endpoint', async () => {
+ const handlers = new Map unknown>();
+ const ipcMain = {
+ handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); },
+ removeHandler: (channel: string) => { handlers.delete(channel); },
+ } as unknown as IpcMain;
+ const profile = {
+ id: 'profile-loopback', label: 'Loopback', apiBaseUrl: 'http://127.42.7.9:43127',
+ createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z',
+ };
+ let listCalls = 0;
+ const credentials = {
+ listProfiles: async () => ({
+ profiles: listCalls++ === 0 ? [] : [profile],
+ activeProfileId: listCalls === 1 ? null : profile.id,
+ }),
+ activate: async () => ({
+ status: 'ready', profileId: profile.id, transportScope: 'scope-loopback', identityEpoch: 'L'.repeat(22),
+ }),
+ } as unknown as DesktopCredentialService;
+ const admitted: string[] = [];
+ let reloads = 0;
+ registerIpcHandlers({
+ app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App,
+ ipcMain,
+ profiles: {} as ProfileStore,
+ credentials,
+ connectDiscovery,
+ lifecycle: {} as LocalLifecycleController,
+ logger: { log: () => undefined } as unknown as DesktopLogger,
+ desktopSession: { clearStorageData: async () => undefined } as unknown as Session,
+ devServerUrl: undefined,
+ packagedRendererUrl: 'propr-renderer://app/index.html',
+ openExternal: async () => undefined,
+ admitRendererEndpoint: origin => {
+ admitted.push(origin);
+ return true;
+ },
+ scheduleRendererPolicyReload: () => { reloads += 1; },
+ });
+ const event = { senderFrame: { url: 'propr-renderer://app/index.html' } } as unknown as IpcMainInvokeEvent;
+
+ const activated = await Promise.resolve(
+ handlers.get(IPC_CHANNELS.connectionActivate)!(event, 'T'.repeat(43)),
+ );
+
+ assert.deepEqual(admitted, [profile.apiBaseUrl]);
+ assert.equal(reloads, 1);
+ assert.deepEqual(activated, {
+ status: 'ready',
+ profileId: profile.id,
+ transportScope: 'scope-loopback',
+ identityEpoch: 'L'.repeat(22),
+ rendererReloadRequired: true,
+ });
+ });
+
it('rejects activation and discards its exact scope when origin storage clearing fails', async () => {
const handlers = new Map unknown>();
const ipcMain = {
diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts
index e2cda64ff..164271e8e 100644
--- a/apps/desktop/src/ipc.ts
+++ b/apps/desktop/src/ipc.ts
@@ -30,6 +30,10 @@ interface RegisterIpcOptions {
devServerUrl: string | undefined;
packagedRendererUrl: string;
openExternal(url: string): Promise;
+ /** Extend the next packaged document policy after a validated activation. */
+ admitRendererEndpoint?(origin: string): boolean;
+ /** Reload after the activation response is delivered when its endpoint needs a new document policy. */
+ scheduleRendererPolicyReload?(): void;
/** @internal Deterministic admitted-work accounting for lifecycle proof. */
observeInvocation?(phase: 'entry' | 'exit', channel: string): void;
/** @internal Fixed, secret-free packaged Connect acceptance evidence. */
@@ -165,6 +169,10 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH
.find(profile => profile.id === after.activeProfileId)?.apiBaseUrl;
const origins = [previousOrigin, activatedOrigin].filter(origin => origin !== undefined);
await clearDesktopInstanceCookies(options.desktopSession, origins);
+ if (activatedOrigin && options.admitRendererEndpoint?.(activatedOrigin)) {
+ options.scheduleRendererPolicyReload?.();
+ return { ...activated, rendererReloadRequired: true as const };
+ }
return activated;
} catch (error) {
await options.credentials.discardActivation({
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 66e1765bd..914bcbe88 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -35,12 +35,14 @@ import {
} from './packaged-approval-session';
import { createDesktopShutdownCoordinator } from './shutdown';
import {
+ applyPackagedRendererCsp,
deepLinkFromArguments,
isSafeExternalUrl,
isTrustedRendererUrl,
normalizeApiBaseUrl,
normalizeDeepLink,
rendererContentSecurityPolicy,
+ rendererCspAllowsConnectUrl,
validatedDevServerUrl,
} from './security';
import {
@@ -156,6 +158,23 @@ interface PackagedTransportSmoke {
}
let activePackagedTransportSmoke: PackagedTransportSmoke | null = null;
let activePackagedConnectJourney = false;
+const rendererCspApiBaseUrls = new Set();
+let loadedRendererCspApiBaseUrls = new Set();
+
+const currentRendererContentSecurityPolicy = (): string => rendererContentSecurityPolicy(
+ !app.isPackaged,
+ [...rendererCspApiBaseUrls],
+);
+
+const admitRendererCspEndpoint = (origin: string): boolean => {
+ if (rendererCspAllowsConnectUrl(origin, [...loadedRendererCspApiBaseUrls])) return false;
+ const normalized = normalizeApiBaseUrl(origin);
+ if (!normalized || normalized !== origin || !rendererCspAllowsConnectUrl(origin, [origin])) {
+ throw new Error('Renderer CSP rejected an invalid profile endpoint');
+ }
+ rendererCspApiBaseUrls.add(origin);
+ return app.isPackaged;
+};
interface PackagedConnectSmoke {
configRoot: string;
@@ -353,7 +372,7 @@ const deliverDeepLink = (value: string): void => {
};
const configurePackagedRendererProtocol = (): (() => void) => {
- protocol.handle(PACKAGED_RENDERER_SCHEME, request => {
+ protocol.handle(PACKAGED_RENDERER_SCHEME, async request => {
const requestUrl = new URL(request.url);
if (requestUrl.hostname !== PACKAGED_RENDERER_HOST) {
return new Response(null, { status: 404 });
@@ -370,7 +389,18 @@ const configurePackagedRendererProtocol = (): (() => void) => {
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return new Response(null, { status: 403 });
}
- return net.fetch(pathToFileURL(filePath).href);
+ const response = await net.fetch(pathToFileURL(filePath).href);
+ if (requestedPath !== 'renderer.html' || !response.ok) return response;
+ const html = await response.text();
+ const transformed = applyPackagedRendererCsp(html, [...rendererCspApiBaseUrls]);
+ loadedRendererCspApiBaseUrls = new Set(rendererCspApiBaseUrls);
+ const headers = new Headers(response.headers);
+ headers.delete('content-length');
+ return new Response(transformed, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
});
return () => { void protocol.unhandle(PACKAGED_RENDERER_SCHEME); };
};
@@ -1164,6 +1194,8 @@ if (!hasSingleInstanceLock) {
decrypt: value => safeStorage.decryptString(value),
};
const profiles = new ProfileStore(app.getPath('userData'), productionEncryption);
+ const initialProfiles = await profiles.list();
+ for (const profile of initialProfiles.profiles) rendererCspApiBaseUrls.add(profile.apiBaseUrl);
const connectDiscovery = new DesktopConnectDiscoveryService(profiles, {
supported: DESKTOP_CONNECT_DISCOVERY_PLATFORMS.has(process.platform),
discover: async () => {
@@ -1213,7 +1245,7 @@ if (!hasSingleInstanceLock) {
connectDiscovery.snapshotIdentityClaim(profileId, origin),
});
const sessionSecurity = configureDesktopSessionSecurity({
- contentSecurityPolicy: () => rendererContentSecurityPolicy(!app.isPackaged),
+ contentSecurityPolicy: currentRendererContentSecurityPolicy,
credentials,
desktopSession: session.defaultSession,
enableRendererNetworkBoundary: process.platform !== 'win32',
@@ -1247,6 +1279,13 @@ if (!hasSingleInstanceLock) {
devServerUrl,
packagedRendererUrl,
openExternal: openAllowedExternalUrl,
+ admitRendererEndpoint: admitRendererCspEndpoint,
+ scheduleRendererPolicyReload: () => {
+ setTimeout(() => {
+ if (!mainWindow || mainWindow.isDestroyed()) return;
+ mainWindow.webContents.reload();
+ }, 0);
+ },
...(journeyStages ? {
reportAcceptanceJourneyStage: (stage: DesktopAcceptanceJourneyStage) => {
journeyStages.record(stage);
diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts
index 69c0f9fd8..b2c55bcb5 100644
--- a/apps/desktop/src/security.test.ts
+++ b/apps/desktop/src/security.test.ts
@@ -4,6 +4,7 @@ import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared';
import {
deepLinkFromArguments,
applyDevelopmentRendererCsp,
+ applyPackagedRendererCsp,
connectApiBaseUrlFromDeepLink,
dashboardPathFromDeepLink,
isSafeExternalUrl,
@@ -12,6 +13,7 @@ import {
normalizeDesktopDashboardPath,
normalizeDeepLink,
rendererContentSecurityPolicy,
+ rendererCspAllowsConnectUrl,
validatedDevServerUrl,
} from './security';
@@ -245,7 +247,62 @@ describe('desktop URL security', () => {
assert.match(policy, /frame-src 'none'/);
assert.doesNotMatch(policy, /unsafe-eval/);
assert.match(policy, /script-src 'self'(?:;|$)/);
- assert.match(policy, /connect-src 'self' https: http: ws: wss:/);
+ assert.match(policy, /connect-src 'self' https: wss:/);
+ assert.doesNotMatch(policy, /(?:^|\s)http:(?:\s|;|$)/);
+ assert.doesNotMatch(policy, /(?:^|\s)ws:(?:\s|;|$)/);
+ assert.match(policy, /http:\/\/localhost:\*/);
+ assert.match(policy, /http:\/\/\*\.localhost:\*/);
+ assert.match(policy, /http:\/\/127\.0\.0\.1:\*/);
+ assert.match(policy, /http:\/\/\[::1\]:\*/);
+ });
+
+ it('limits cleartext renderer connections to canonical loopback policy sources', () => {
+ const configured = ['http://127.42.7.9:43127'];
+ for (const url of [
+ 'http://localhost:4000/api/tasks',
+ 'ws://localhost:4000/socket.io/',
+ 'http://api.dev.localhost:5173/api/tasks',
+ 'ws://api.dev.localhost:5173/socket.io/',
+ 'http://127.0.0.1:65535/api/tasks',
+ 'ws://127.0.0.1:65535/socket.io/',
+ 'http://[::1]:4000/api/tasks',
+ 'ws://[::1]:4000/socket.io/',
+ 'http://127.42.7.9:43127/api/tasks',
+ 'ws://127.42.7.9:43127/socket.io/',
+ 'https://remote.example.test/api/tasks',
+ 'wss://t-instance123.propr.dev/socket.io/',
+ ]) assert.equal(rendererCspAllowsConnectUrl(url, configured), true, url);
+
+ for (const url of [
+ 'http://192.168.1.20:4000/api/tasks',
+ 'ws://10.0.0.8:4000/socket.io/',
+ 'http://example.test/api/tasks',
+ 'ws://example.test/socket.io/',
+ 'http://localhost.example.test:4000/api/tasks',
+ 'http://localhost.:4000/api/tasks',
+ 'ws://api.dev.localhost.:5173/socket.io/',
+ 'ws://127.42.7.8:43127/socket.io/',
+ 'http://127.1:4000/api/tasks',
+ 'http://0177.0.0.1:4000/api/tasks',
+ 'http://0x7f000001:4000/api/tasks',
+ 'http://[::ffff:127.0.0.1]:4000/api/tasks',
+ 'http://local%68ost:4000/api/tasks',
+ ]) assert.equal(rendererCspAllowsConnectUrl(url, configured), false, url);
+ });
+
+ it('injects exact non-default 127/8 HTTP and WebSocket sources into packaged HTML', () => {
+ const baseline = rendererContentSecurityPolicy();
+ const html = ``;
+ const transformed = applyPackagedRendererCsp(html, [
+ 'http://127.42.7.9:43127',
+ 'http://192.168.1.20:4000',
+ 'https://remote.example.test',
+ ]);
+
+ assert.match(transformed, /http:\/\/127\.42\.7\.9:43127/);
+ assert.match(transformed, /ws:\/\/127\.42\.7\.9:43127/);
+ assert.doesNotMatch(transformed, /192\.168\.1\.20/);
+ assert.doesNotMatch(transformed, /remote\.example\.test/);
});
it('relaxes inline scripts only while Vite serves the development renderer', () => {
diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts
index 7d4931734..0d987ddfc 100644
--- a/apps/desktop/src/security.ts
+++ b/apps/desktop/src/security.ts
@@ -193,15 +193,83 @@ export const deepLinkFromArguments = (argv: readonly string[]): string | null =>
return null;
};
-export const rendererContentSecurityPolicy = (development = false): string => [
+const STATIC_RENDERER_CLEARTEXT_CONNECT_SOURCES = [
+ 'http://localhost:*',
+ 'http://*.localhost:*',
+ 'http://127.0.0.1:*',
+ 'http://[::1]:*',
+ 'ws://localhost:*',
+ 'ws://*.localhost:*',
+ 'ws://127.0.0.1:*',
+ 'ws://[::1]:*',
+] as const;
+
+const staticallyAllowedRendererLoopbackHostname = (hostname: string): boolean => {
+ const normalized = hostname.toLowerCase();
+ return normalized === 'localhost'
+ || normalized.endsWith('.localhost')
+ || normalized === '127.0.0.1'
+ || normalized === '[::1]';
+};
+
+const rendererEndpointOrigins = (apiBaseUrls: readonly string[]): Set => {
+ const origins = new Set();
+ for (const value of apiBaseUrls) {
+ const normalized = normalizeApiBaseUrl(value);
+ if (!normalized) continue;
+ const url = new URL(normalized);
+ if (url.protocol === 'http:' && isProprLoopbackHostname(url.hostname)) origins.add(normalized);
+ }
+ return origins;
+};
+
+const rendererProfileConnectSources = (apiBaseUrls: readonly string[]): string[] => {
+ const sources: string[] = [];
+ for (const origin of rendererEndpointOrigins(apiBaseUrls)) {
+ const url = new URL(origin);
+ if (staticallyAllowedRendererLoopbackHostname(url.hostname)) continue;
+ sources.push(origin, `ws://${url.host}`);
+ }
+ return sources.sort();
+};
+
+/** Match the generated connect-src boundary without relying on URL canonicalization aliases. */
+export const rendererCspAllowsConnectUrl = (
+ value: string,
+ apiBaseUrls: readonly string[] = [],
+): boolean => {
+ let parsed: URL;
+ try {
+ parsed = new URL(value);
+ } catch {
+ return false;
+ }
+ if (parsed.username || parsed.password) return false;
+ if (parsed.protocol === 'https:' || parsed.protocol === 'wss:') return true;
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'ws:') return false;
+
+ const httpCandidate = parsed.protocol === 'ws:' ? `http:${value.slice('ws:'.length)}` : value;
+ const canonicalOrigin = canonicalProprHttpUrlOrigin(httpCandidate);
+ if (!canonicalOrigin) return false;
+ const canonicalUrl = new URL(canonicalOrigin);
+ if (staticallyAllowedRendererLoopbackHostname(canonicalUrl.hostname)) return true;
+ return rendererEndpointOrigins(apiBaseUrls).has(canonicalOrigin);
+};
+
+export const rendererContentSecurityPolicy = (
+ development = false,
+ apiBaseUrls: readonly string[] = [],
+): string => [
"default-src 'self'",
`script-src 'self'${development ? " 'unsafe-inline'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
- // Electron main applies the shared canonical origin rule before any request;
- // scheme sources are required here because CSP cannot express IPv4 127/8.
- "connect-src 'self' https: http: ws: wss:",
+ [
+ "connect-src 'self' https: wss:",
+ ...STATIC_RENDERER_CLEARTEXT_CONNECT_SOURCES,
+ ...rendererProfileConnectSources(apiBaseUrls),
+ ].join(' '),
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
@@ -215,3 +283,15 @@ export const applyDevelopmentRendererCsp = (html: string): string => {
}
return html.replace(packagedPolicy, rendererContentSecurityPolicy(true));
};
+
+/** Replace the build-time baseline with the policy prepared before packaged navigation. */
+export const applyPackagedRendererCsp = (
+ html: string,
+ apiBaseUrls: readonly string[],
+): string => {
+ const packagedPolicy = rendererContentSecurityPolicy();
+ if (!html.includes(packagedPolicy)) {
+ throw new Error('renderer.html is missing the packaged content security policy');
+ }
+ return html.replace(packagedPolicy, rendererContentSecurityPolicy(false, apiBaseUrls));
+};
diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts
index 2fe16b87e..9060e7ad8 100644
--- a/apps/desktop/src/shared/contract.ts
+++ b/apps/desktop/src/shared/contract.ts
@@ -93,6 +93,8 @@ export interface DesktopConnectionScope {
export interface DesktopActivatedConnection extends DesktopConnectionScope {
status: 'ready';
identityEpoch: string;
+ /** The main process committed a stricter replacement document policy and scheduled a reload. */
+ rendererReloadRequired?: true;
}
export interface DesktopAccessInvalidation extends DesktopConnectionScope {
diff --git a/propr-ui/src/desktop/DesktopExperience.transport.test.tsx b/propr-ui/src/desktop/DesktopExperience.transport.test.tsx
index b183b1030..4894c7941 100644
--- a/propr-ui/src/desktop/DesktopExperience.transport.test.tsx
+++ b/propr-ui/src/desktop/DesktopExperience.transport.test.tsx
@@ -150,6 +150,29 @@ describe('DesktopExperience transport and fencing', () => {
expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled();
});
+ it('does not publish renderer networking before a replacement document CSP reload', async () => {
+ const adapters = adaptersFor([localProfile], localProfile.id, async () => ({
+ status: 'ready', version: '0.8.15', activationTicket: 'ticket-loopback',
+ }));
+ adapters.connection.activate = vi.fn(async () => ({
+ status: 'ready' as const,
+ profileId: localProfile.id,
+ transportScope: 'scope-loopback',
+ identityEpoch: 'L'.repeat(22),
+ rendererReloadRequired: true as const,
+ }));
+ adapters.connection.publishActivation = vi.fn();
+
+ render(Premature network app
);
+
+ await waitFor(() => expect(adapters.connection.activate).toHaveBeenCalledOnce());
+ expect(screen.getByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument();
+ expect(screen.queryByText('Premature network app')).not.toBeInTheDocument();
+ expect(adapters.connection.publishActivation).not.toHaveBeenCalled();
+ expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled();
+ expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled();
+ });
+
it('ignores a stale connection result after the adapters change', async () => {
let resolveFirstProbe: ((result: DesktopConnectionResult) => void) | undefined;
const firstProbe = vi.fn(() => new Promise(resolve => {
diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx
index 30e9c099e..f4596d815 100644
--- a/propr-ui/src/desktop/DesktopExperience.tsx
+++ b/propr-ui/src/desktop/DesktopExperience.tsx
@@ -115,6 +115,9 @@ export const DesktopExperience: React.FC = ({ adapters,
}
});
if (!isCurrentAttempt()) return;
+ // Main has already committed the active profile and schedules a reload.
+ // Do not publish an endpoint until the replacement document CSP is active.
+ if (result.status === 'ready' && result.rendererReloadRequired) return;
setProfiles(current => mergeProfiles(current, [connectedProfile]));
if (result.status !== 'ready') {
setState({ phase: 'blocked', profile: connectedProfile, result });
diff --git a/propr-ui/src/desktop/electronAdapters.test.ts b/propr-ui/src/desktop/electronAdapters.test.ts
index 711cab66f..ab42fa5fa 100644
--- a/propr-ui/src/desktop/electronAdapters.test.ts
+++ b/propr-ui/src/desktop/electronAdapters.test.ts
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared';
-import type { DesktopBridge, DesktopProfile as StoredProfile } from '../../../apps/desktop/src/shared/contract';
+import type {
+ DesktopActivatedConnection,
+ DesktopBridge,
+ DesktopProfile as StoredProfile,
+} from '../../../apps/desktop/src/shared/contract';
import { createElectronDesktopAdapters } from './electronAdapters';
const desktopConnectionState = vi.hoisted(() => ({
@@ -32,7 +36,7 @@ const bridgeFixture = () => {
version: '0.8.15',
activationTicket: 'ticket-7',
}));
- const activate = vi.fn(async () => ({
+ const activate = vi.fn(async (): Promise => ({
status: 'ready' as const,
profileId: storedProfile.id,
transportScope: 'scope-7',
@@ -219,6 +223,30 @@ describe('Electron remote instance adapters', () => {
});
});
+ it('preserves the main-process replacement-policy reload marker', async () => {
+ const fixture = bridgeFixture();
+ fixture.activate.mockResolvedValueOnce({
+ status: 'ready',
+ profileId: storedProfile.id,
+ transportScope: 'scope-reload',
+ identityEpoch: 'R'.repeat(22),
+ rendererReloadRequired: true,
+ });
+ const adapters = createElectronDesktopAdapters(fixture.bridge);
+ const profile = (await adapters.profiles.list())[0];
+
+ const activated = await adapters.connection.activate?.(profile, {
+ status: 'ready', activationTicket: 'ticket-reload',
+ });
+
+ expect(activated).toEqual(expect.objectContaining({
+ status: 'ready',
+ transportScope: 'scope-reload',
+ rendererReloadRequired: true,
+ }));
+ expect(setDesktopConnectionScope).not.toHaveBeenCalled();
+ });
+
it('clears renderer storage after a successful same-origin profile switch', async () => {
const fixture = bridgeFixture();
await fixture.bridge.profiles.save({
diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts
index f98cc53ea..0812ba6d6 100644
--- a/propr-ui/src/desktop/electronAdapters.ts
+++ b/propr-ui/src/desktop/electronAdapters.ts
@@ -201,6 +201,7 @@ export const createElectronDesktopAdapters = (bridge: DesktopBridge): DesktopAda
profileId: activated.profileId,
transportScope: activated.transportScope,
identityEpoch: activated.identityEpoch,
+ ...(activated.rendererReloadRequired ? { rendererReloadRequired: true as const } : {}),
};
},
publishActivation(profile, result) {
diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts
index c6b3f3cbe..4d1a47a34 100644
--- a/propr-ui/src/desktop/types.ts
+++ b/propr-ui/src/desktop/types.ts
@@ -9,7 +9,7 @@ export interface DesktopProfile {
}
export type DesktopConnectionResult =
- | { status: 'ready'; version?: string; authentication?: string; activationTicket?: string; transportScope?: string; profileId?: string; identityEpoch?: string }
+ | { status: 'ready'; version?: string; authentication?: string; activationTicket?: string; transportScope?: string; profileId?: string; identityEpoch?: string; rendererReloadRequired?: true }
| { status: 'authentication-required'; message?: string; version?: string; authentication?: string }
| { status: 'incompatible'; message: string; version?: string }
| { status: 'offline'; message: string };