From fcf31df928fca1660a668da0f563502393d23022 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:45:45 +0100 Subject: [PATCH] fix: keep one policy object per change so the login page renders in the desktop app --- lib/__tests__/plugin-device-policy.test.ts | 2 +- lib/__tests__/plugin-sandbox-loader.test.ts | 6 +++--- lib/desktop/__tests__/policy-client.test.tsx | 13 ++++++++++++ lib/desktop/policy.ts | 21 +++++++++++++++++++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/plugin-device-policy.test.ts b/lib/__tests__/plugin-device-policy.test.ts index 63c0a0ded..815c56a3f 100644 --- a/lib/__tests__/plugin-device-policy.test.ts +++ b/lib/__tests__/plugin-device-policy.test.ts @@ -97,7 +97,7 @@ function serve(plugins: unknown[]) { beforeEach(() => { vi.clearAllMocks(); usePluginStore.setState({ plugins: [], initialized: false }); - harness.current = snapshotOf({}); + setPolicy({}); localStorage.clear(); usePluginStore.setState({ plugins: [], initialized: false }); }); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 4614e23e3..a09c8511d 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -106,8 +106,8 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { it('starts nothing the policy refuses: no frame, no download', async () => { const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../desktop/policy-schema'); const snapshot = (forcedMachine: Record) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine }); - let current = snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] }); - window.bulwarkDesktop = { version: 1, platform: 'win32', policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }; + const bridgeWith = (current: ReturnType) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }); + window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] })); mocks.getCode.mockResolvedValue(CODE); try { await loadSandboxedPlugin(plugin()); @@ -116,7 +116,7 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { expect(mocks.downloadManagedBundle).not.toHaveBeenCalled(); expect(mocks.getCode).not.toHaveBeenCalled(); - current = snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] }); + window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] })); await loadSandboxedPlugin(plugin()); expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' }); } finally { diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx index 6d81f29d5..dd35e920d 100644 --- a/lib/desktop/__tests__/policy-client.test.tsx +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -85,6 +85,19 @@ describe('in the app', () => { expect(devicesForAccount('some-account').noOffline).toBe(false); }, 60_000); + it('the hook settles on a bridge that copies what it returns, as contextBridge does', async () => { + setBridgePolicy(snapshotOf({ PluginsMode: 'off' })); + window.bulwarkDesktop!.policy!.get = () => JSON.parse(JSON.stringify(current)); + let renders = 0; + function Probe() { + renders += 1; + return {useDesktopPolicy().PluginsMode.value}; + } + render(); + expect(screen.getByText('off')).toBeInTheDocument(); + expect(renders).toBeLessThan(4); + }); + it('the hook shows a change at once', async () => { setBridgePolicy(snapshotOf({})); function Probe() { diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts index 63b1d4f72..3c3fad5a3 100644 --- a/lib/desktop/policy.ts +++ b/lib/desktop/policy.ts @@ -8,6 +8,7 @@ import { useSyncExternalStore } from 'react'; import { getDesktopBridge } from './bridge'; +import type { DesktopPolicyBridge } from './types'; import { DEFAULT_POLICY_SNAPSHOT, effectivePolicyValue, @@ -21,11 +22,29 @@ import { /** A plugin the device's policy refuses, on a plugin's record and in the settings page. */ export const PLUGIN_POLICY_REFUSED_ERROR = 'device-policy'; +let cached: { bridge: DesktopPolicyBridge; snapshot: PolicySnapshot } | null = null; + +/** + * The same object until the policy changes. The bridge can't give that: contextBridge + * copies what it returns, so each `get()` is a new object, and useSyncExternalStore takes + * a new object for a change and renders forever. + */ export function getDesktopPolicy(): PolicySnapshot { - return getDesktopBridge()?.policy?.get?.() ?? DEFAULT_POLICY_SNAPSHOT; + const bridge = getDesktopBridge()?.policy; + if (!bridge?.get) return DEFAULT_POLICY_SNAPSHOT; + if (cached?.bridge !== bridge) { + const entry = { bridge, snapshot: bridge.get() }; + cached = entry; + bridge.onChange?.((snapshot) => { + entry.snapshot = snapshot; + }); + } + return cached.snapshot; } export function onDesktopPolicyChange(listener: (snapshot: PolicySnapshot) => void): () => void { + // First, so what the listener reads is already the new value. + getDesktopPolicy(); return getDesktopBridge()?.policy?.onChange?.(listener) ?? (() => {}); }