Visitar URL original
fix(hub-ui): initialize dock page scripts before activation by dvcolomban · Pull Request #387 · devframes/devframe · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(hub): recheck trust after client script imports
  • Loading branch information
dvcolomban committed Sep 14, 2026
commit 91291de9f351ee7bac8664dfa0c46636bc81393c
78 changes: 78 additions & 0 deletions packages/hub-ui/src/client/state/client-script.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,81 @@ it('awaits an eager page setup before activation and retries it after failure',
await expect(activation).resolves.toBe(true)
expect(attempts).toBe(2)
})

it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
expect.assertions(6)
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rpc, sharedStates: states } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const docks = context.docks
const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_UI__?: () => Promise<void>, __DF_IMPORT_SETUP_UI__?: () => void }
let releaseImport!: () => void
const importGate = new Promise<void>((resolve) => {
releaseImport = resolve
})
const importing = vi.fn(() => importGate)
const setup = vi.fn()
fixture.__DF_IMPORT_GATE_UI__ = importing
fixture.__DF_IMPORT_SETUP_UI__ = setup
const entry = {
id: `revoked-import-${eager}`,
type: 'iframe',
title: 'Revoked import',
icon: 'ph:browser',
url: '/fixture',
clientScript: {
eager,
importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_UI__(); export default () => globalThis.__DF_IMPORT_SETUP_UI__(); // ${eager}`,
},
} satisfies DevframeDockEntry
try {
states.get('devframe:docks')!.push([entry])
const activation = docks.switchEntry(entry.id)
const rejected = expect(activation).rejects.toThrow('no longer trusted')
await expect.poll(() => importing.mock.calls.length).toBe(1)
Object.assign(rpc, { isTrusted: false })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
releaseImport()
await rejected
expect(setup).not.toHaveBeenCalled()
expect(docks.selectedId).toBeNull()
Object.assign(rpc, { isTrusted: true })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
await expect(docks.switchEntry(entry.id)).resolves.toBe(true)
expect(setup).toHaveBeenCalledOnce()
}
finally {
releaseImport()
delete fixture.__DF_IMPORT_GATE_UI__
delete fixture.__DF_IMPORT_SETUP_UI__
reportError.mockRestore()
}
})

it('does not activate an iframe when trust is lost while its setup completes', async () => {
expect.assertions(2)
const { rpc, sharedStates: states } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const docks = context.docks
const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_UI__?: () => void }
fixture.__DF_SETUP_REVOKE_UI__ = () => {
Object.assign(rpc, { isTrusted: false })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
}
const entry = {
id: 'revoked-during-setup',
type: 'iframe',
title: 'Revoked setup',
icon: 'ph:browser',
url: '/fixture',
clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_UI__()' },
} satisfies DevframeDockEntry
try {
states.get('devframe:docks')!.push([entry])
await expect(docks.switchEntry(entry.id)).resolves.toBe(false)
expect(docks.selectedId).toBeNull()
}
finally {
delete fixture.__DF_SETUP_REVOKE_UI__
}
})
2 changes: 2 additions & 0 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ export async function createDocksContext(
if (!rpc.isTrusted)
return false
await runPageScript(entry)
if (!rpc.isTrusted)
return false

initialRestorePending.value = false
selectedDockId.value = entry.id
Expand Down
3 changes: 3 additions & 0 deletions packages/hub-ui/src/client/state/setup-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ async function _executeSetupScript(
const fn = mod[script.importName ?? 'default']
if (typeof fn !== 'function')
throw new Error(`[@devframes/hub-ui] "${specifier}" exports no callable "${script.importName ?? 'default'}"`)
/** Trust may change while the module is loading; rejection keeps setup retryable. */
if (!context.rpc.isTrusted)
throw new Error('[@devframes/hub-ui] RPC client is no longer trusted')
await fn(context)
}
catch (error) {
Expand Down
80 changes: 80 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,83 @@ it('waits for trust for eager setup and activation for lazy setup in the headles
delete fixture.__DF_LAZY_TEST__
}
})

it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
expect.assertions(6)
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rpc, states } = createStubRpc()
const runtime = await createDevframeClientRuntime({ rpc })
const docks = runtime.context.docks
const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_HEADLESS__?: () => Promise<void>, __DF_IMPORT_SETUP_HEADLESS__?: () => void }
let releaseImport!: () => void
const importGate = new Promise<void>((resolve) => {
releaseImport = resolve
})
const importing = vi.fn(() => importGate)
const setup = vi.fn()
fixture.__DF_IMPORT_GATE_HEADLESS__ = importing
fixture.__DF_IMPORT_SETUP_HEADLESS__ = setup
const entry = {
id: `revoked-import-${eager}`,
type: 'iframe',
title: 'Revoked import',
icon: 'ph:browser',
url: '/fixture',
clientScript: {
eager,
importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_HEADLESS__(); export default () => globalThis.__DF_IMPORT_SETUP_HEADLESS__(); // ${eager}`,
},
} satisfies DevframeDockEntry
try {
states.get('devframe:docks')!.push([entry])
const activation = docks.switchEntry(entry.id)
const rejected = expect(activation).rejects.toThrow('no longer trusted')
await expect.poll(() => importing.mock.calls.length).toBe(1)
Object.assign(rpc, { isTrusted: false })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
releaseImport()
await rejected
expect(setup).not.toHaveBeenCalled()
expect(docks.selectedId).toBeNull()
Object.assign(rpc, { isTrusted: true })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
await expect(docks.switchEntry(entry.id)).resolves.toBe(true)
expect(setup).toHaveBeenCalledOnce()
}
finally {
releaseImport()
delete fixture.__DF_IMPORT_GATE_HEADLESS__
delete fixture.__DF_IMPORT_SETUP_HEADLESS__
reportError.mockRestore()
runtime.dispose()
}
})

it('does not activate an iframe when trust is lost while its setup completes', async () => {
expect.assertions(2)
const { rpc, states } = createStubRpc()
const runtime = await createDevframeClientRuntime({ rpc })
const docks = runtime.context.docks
const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_HEADLESS__?: () => void }
fixture.__DF_SETUP_REVOKE_HEADLESS__ = () => {
Object.assign(rpc, { isTrusted: false })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
}
const entry = {
id: 'revoked-during-setup',
type: 'iframe',
title: 'Revoked setup',
icon: 'ph:browser',
url: '/fixture',
clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_HEADLESS__()' },
} satisfies DevframeDockEntry
try {
states.get('devframe:docks')!.push([entry])
await expect(docks.switchEntry(entry.id)).resolves.toBe(false)
expect(docks.selectedId).toBeNull()
}
finally {
delete fixture.__DF_SETUP_REVOKE_HEADLESS__
runtime.dispose()
}
})
7 changes: 5 additions & 2 deletions packages/hub/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export async function createDevframeClientRuntime(
return false
if (entry.type === 'iframe' && entry.clientScript)
await setupClientScript(entry.id, entry.clientScript)
return !disposed && entryToStateMap.get(entry.id)?.entryMeta === entry
return !disposed && rpc.isTrusted && entryToStateMap.get(entry.id)?.entryMeta === entry
}

async function runActivationScript(entry: DevframeDockEntry): Promise<void> {
Expand Down Expand Up @@ -606,8 +606,11 @@ export async function createDevframeClientRuntime(
if (typeof fn !== 'function')
throw new Error(`[@devframes/hub] "${specifier}" exports no callable "${script.importName ?? 'default'}"`)
const current = entryToStateMap.get(entryId)
if (!current || disposed || !rpc.isTrusted)
if (!current || disposed)
return
/** Reject instead of caching skipped setup so re-authentication can retry it. */
if (!rpc.isTrusted)
throw new Error('[@devframes/hub] RPC client is no longer trusted')
// Scope the messages client to this entry: its messages default their
// `category` to the entry id, so the feed can attribute and group them.
const messages = createMessagesClient(rpc, { defaults: { category: entryId } })
Expand Down
Loading