Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ reinjection.
The notification controller is headless. The optional Shadow DOM renderer supplies the default
light/dark theme, typed CSS-variable customization and runtime theme updates without rebuilding
controls. Hosts choose branding, placement and approval callbacks. A host using Devframe
notifications uses its message API instead of injecting the default renderer.
notifications uses its message API instead of injecting the default renderer. The panel host owns
each shared Devframe notification. Viewing tabs own matching local command registrations, keeping
approval in the clicked tab while avoiding duplicate messages across connected tabs.

### Automation providers

Expand Down
6 changes: 4 additions & 2 deletions packages/devframe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const panel = createCdbPanel({
});
```

The renderer must support Devframe's JSON view and action contracts. Component overrides apply only to that mounted renderer; the standalone SPA uses the reference catalogue. Dock ordering follows the host's categories and saved preferences. The page script remains available through `dock.clientScript` for host notifications and review intents.
The renderer must support Devframe's JSON view and action contracts. Component overrides apply only to that mounted renderer; the standalone SPA uses the reference catalogue. Dock ordering follows the host's categories and saved preferences. The page script remains available through `dock.clientScript` for tab-local notification commands and review intents.

For an existing application container, import `mountBrowserControlPanel` from `@dvcol/cdb-devframe/panel` and pass a `client`, `container`, optional branding/CSS and an `onReview` callback. Unmounting releases the panel subscription without disposing that client.

Expand All @@ -53,7 +53,9 @@ Hosts with a direct approval channel can set `approvalAction: 'accept'` on `crea

See the [runnable example](../../examples/devframe/README.md).

Notification descriptions and approved-tab counts update through the existing Devframe message handle. Changed content retains its message ID and follows the host's normal notification behavior, including resurfacing a dismissed toast. Unchanged broker publications do not update the message. Request completion, expiry and disposal remove its message and command.
The panel host publishes one notification per request or approved scope into Devframe’s shared message feed. Each viewing tab registers the matching command locally, so approval executes in the tab where the user clicks it. Closing a tab releases its command registrations without removing the shared message.

Notification descriptions and approved-tab counts update through the existing Devframe message handle. Changed content retains its message ID and follows the host's normal notification behavior, including resurfacing a dismissed toast. Unchanged broker publications do not update the message. Request completion, expiry and panel-host disposal remove the shared message. Each page removes its command when its subscription reports that the request or scope ended.

The public validation workspace backports Devframe's toast-removal fix to hub-ui 0.9.10 using [a temporary pnpm patch](https://github.com/dvcol/chrome-debugger-bridge/blob/main/patches/README.md). This workspace patch is not inherited by consumers of the published CDB package; embedding applications using that hub-ui version must apply the patch themselves until adopting an upstream version containing the fix.

Expand Down
39 changes: 39 additions & 0 deletions packages/devframe/src/notification-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { BrowserControlNotification } from '@dvcol/cdb-extension/notifications';

export interface BrowserControlNotificationItem {
readonly id: string;
readonly requestId: string;
readonly kind: 'request' | 'grant';
readonly title: string;
readonly description: string;
readonly label: string;
}

/** Shared message IDs resolve to a command installed independently in each viewing tab. */
export function browserControlNotificationItems(state: BrowserControlNotification, approvalAction: 'review' | 'accept' = 'review'): BrowserControlNotificationItem[] {
const items: BrowserControlNotificationItem[] = state.requests.map(request => ({
id: `cdb:browser-control:request:${request.id}`,
requestId: request.id,
kind: 'request',
title: 'Browser control requested',
description: `${request.principalLabel} requests ${request.level} access with ${request.navigation} navigation.`,
label: approvalAction === 'accept' ? 'Accept' : 'Review request',
}));
for (const [requestId, grants] of Map.groupBy(state.grants, grant => grant.requestId)) {
const grant = grants[0]!;
const tabLabel = grants.length === 1 ? 'tab' : 'tabs';
items.push({
id: `cdb:browser-control:grant:${requestId}`,
requestId,
kind: 'grant',
title: 'Browser control active',
description: `${grant.principalLabel}: ${grant.level} access to ${grants.length} approved ${tabLabel}.`,
label: 'Stop control',
});
}
return items;
}

export interface BrowserControlMessages {
info: (message: string, options: { id: string; description: string; notify: boolean; autoDismiss: false; actions: { id: string; label: string; kind: 'command'; command: { id: string } }[] }) => Promise<{ dismiss: () => Promise<void>; update: (patch: { description: string }) => Promise<unknown> }>;
}
73 changes: 73 additions & 0 deletions packages/devframe/src/notification-publisher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { BrowserControlNotificationController } from '@dvcol/cdb-extension/notifications';

import type { BrowserControlMessages, BrowserControlNotificationItem } from './notification-items.js';

import { styleText } from 'node:util';

import { browserControlNotificationItems } from './notification-items.js';

/** The panel host owns shared messages; page clients own only their local command registrations. */
export function publishBrowserControlNotifications(controller: BrowserControlNotificationController, messages: BrowserControlMessages, approvalAction: 'review' | 'accept' = 'review'): () => void {
const notifications = new Map<string, { description: string; update: (description: string) => void; remove: () => void }>();
const pending = new Map<string, Promise<void>>();

/** Keep removal ordered before re-creation of the same ID during connection replacement. */
function enqueue(id: string, operation: () => Promise<void>): void {
const next = (pending.get(id) ?? Promise.resolve()).then(operation).catch((error) => {
console.error(styleText('red', '❌ [cdb]'), 'Unable to publish browser-control notification.', error);
});
pending.set(id, next);
void next.then(() => {
if (pending.get(id) === next) pending.delete(id);
});
}

function createNotification(item: BrowserControlNotificationItem): void {
let active = true;
let handle: Awaited<ReturnType<BrowserControlMessages['info']>> | undefined;
enqueue(item.id, async () => {
if (!active) return;
handle = await messages.info(item.title, { id: item.id, description: item.description, notify: true, autoDismiss: false, actions: [{ id: 'control', label: item.label, kind: 'command', command: { id: item.id } }] });
});
notifications.set(item.id, {
description: item.description,
update(description) {
enqueue(item.id, async () => {
if (active) await handle?.update({ description });
});
},
remove() {
active = false;
enqueue(item.id, async () => {
await handle?.dismiss();
});
},
});
}

const unsubscribe = controller.subscribe((state) => {
const items = browserControlNotificationItems(state, approvalAction);
const activeIds = new Set(items.map(item => item.id));
for (const [id, notification] of notifications) {
if (activeIds.has(id)) continue;
notification.remove();
notifications.delete(id);
}
for (const item of items) {
const notification = notifications.get(item.id);
if (notification === undefined) {
createNotification(item);
continue;
}
if (notification.description === item.description) continue;
notification.description = item.description;
notification.update(item.description);
}
});

return function dispose(): void {
unsubscribe();
for (const notification of notifications.values()) notification.remove();
notifications.clear();
};
}
61 changes: 20 additions & 41 deletions packages/devframe/src/page-script.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { DevframeRpcClient } from 'devframe/client';

import type { BrowserControlMessages } from './notification-items.js';

import { createBrowserControlNotificationController } from '@dvcol/cdb-extension/notifications';

import { browserControlNotificationItems } from './notification-items.js';
import { createBrowserControlPanelClient } from './panel.js';

/** Page events request the host's final approval UI. They never approve browser access. */
Expand All @@ -26,9 +29,8 @@ export interface BrowserControlPageContext {
readonly commands: {
register: (command: { id: string; title: string; source: 'client'; action: () => Promise<void>; showInPalette: boolean }) => () => void;
};
readonly messages: {
info: (message: string, options: { id: string; description: string; notify: boolean; autoDismiss: false; actions: { id: string; label: string; kind: 'command'; command: { id: string } }[] }) => Promise<{ dismiss: () => Promise<void>; update: (patch: { description: string }) => Promise<unknown> }>;
};
/** Retained for compatibility with existing page hosts; messages are published by the panel host. */
readonly messages: BrowserControlMessages;
}

/** Uses the hub's existing page connection; the embedding extension handles the review intent. */
Expand All @@ -42,46 +44,23 @@ export default async function setupBrowserControlPage(context: BrowserControlPag
window.dispatchEvent(new CustomEvent(options.approvalAction === 'accept' ? browserControlAcceptEvent : browserControlReviewEvent, { detail: { requestId } }));
};
const controller = createBrowserControlNotificationController({ onReview: request => review(request.id), onRevoke: async requestId => client.revokeScope(requestId) });
const notifications = new Map<string, { description: string; update: (description: string) => void; remove: () => void }>();
const prefix = `cdb:browser-control:${crypto.randomUUID()}`;
const commands = new Map<string, () => void>();
const stopNotifications = controller.subscribe((state) => {
const items = [
...state.requests.map(request => ({ id: `request:${request.id}`, title: 'Browser control requested', description: `${request.principalLabel} requests ${request.level} access with ${request.navigation} navigation.`, label: options.approvalAction === 'accept' ? 'Accept' : 'Review request', action: async () => controller.review(request.id) })),
...Array.from(Map.groupBy(state.grants, grant => grant.requestId), ([requestId, grants]) => ({ id: `grant:${requestId}`, title: 'Browser control active', description: `${grants[0]!.principalLabel}: ${grants[0]!.level} access to ${grants.length} approved ${grants.length === 1 ? 'tab' : 'tabs'}.`, label: 'Stop control', action: async () => controller.revoke(requestId) })),
];
for (const [id, notification] of notifications) {
if (items.some(item => item.id === id)) continue;
notification.remove();
notifications.delete(id);
const items = browserControlNotificationItems(state, options.approvalAction);
const activeIds = new Set(items.map(item => item.id));
for (const [id, unregister] of commands) {
if (activeIds.has(id)) continue;
unregister();
commands.delete(id);
}
for (const item of items) {
const notification = notifications.get(item.id);
if (notification !== undefined) {
if (notification.description !== item.description) {
notification.description = item.description;
notification.update(item.description);
}
continue;
if (commands.has(item.id)) continue;
async function invoke(): Promise<void> {
if (item.kind === 'request') return controller.review(item.requestId);
await controller.revoke(item.requestId);
}
const id = `${prefix}:${item.id}`;
const unregister = context.commands.register({ id, title: item.label, source: 'client', action: item.action, showInPalette: false });
const message = context.messages.info(item.title, { id, description: item.description, notify: true, autoDismiss: false, actions: [{ id: 'control', label: item.label, kind: 'command', command: { id } }] }).catch(error => console.error('Unable to show browser-control notification.', error));
let active = true;
let pending = Promise.resolve();
notifications.set(item.id, {
description: item.description,
update(description) {
pending = pending.then(async () => {
const handle = await message;
if (active) await handle?.update({ description });
}).catch(error => console.error('Unable to update browser-control notification.', error));
},
remove() {
active = false;
unregister();
pending = pending.then(async () => (await message)?.dismiss()).catch(error => console.error('Unable to dismiss browser-control notification.', error));
},
});
const unregister = context.commands.register({ id: item.id, title: item.label, source: 'client', action: invoke, showInPalette: false });
commands.set(item.id, unregister);
}
});
const receive = (event: MessageEvent<unknown>): void => {
Expand All @@ -98,8 +77,8 @@ export default async function setupBrowserControlPage(context: BrowserControlPag
stopWatching?.();
document.documentElement.removeAttribute('data-cdb-notifications-ready');
stopNotifications();
for (const notification of notifications.values()) notification.remove();
notifications.clear();
for (const unregister of commands.values()) unregister();
commands.clear();
controller.dispose();
window.removeEventListener('message', receive);
window.removeEventListener('pagehide', dispose);
Expand Down
17 changes: 17 additions & 0 deletions packages/devframe/src/panel-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { JsonRenderView } from '@devframes/json-render';
import type { BrokerState } from '@dvcol/cdb-broker/contract';
import type { DevframeDefinition, DevframeNodeContext, DevframeScopedNodeRpc } from 'devframe';

import type { BrowserControlMessages } from './notification-items.js';
import type { BrowserControlPanelComponents } from './panel-view.js';
import type { BrowserControlPanelClient } from './panel.js';

Expand All @@ -12,12 +13,16 @@ import { fileURLToPath } from 'node:url';
import { jsonRenderSpaDir } from '@devframes/json-render-ui/spa';
import { toJsonRenderDockEntry } from '@devframes/json-render/hub';
import { createJsonRenderView } from '@devframes/json-render/node';
import { createBrowserControlNotificationController } from '@dvcol/cdb-extension/notifications';

import packageManifest from '../package.json' with { type: 'json' };
import { publishBrowserControlNotifications } from './notification-publisher.js';
import { buildBrowserControlPanelView } from './panel-view.js';

/** Hosts may augment their RPC catalogue without exporting those declarations to CDB. */
interface PanelContext {
/** Hub hosts supply their shared message feed. Plain view hosts may omit notifications. */
readonly messages?: BrowserControlMessages;
rpc: { sharedState: Pick<DevframeNodeContext['rpc']['sharedState'], 'get'> };
scope: (namespace: string) => { readonly rpc: Pick<DevframeScopedNodeRpc, 'sharedState'> & {
register: (definition: Pick<Parameters<DevframeScopedNodeRpc['register']>[0], 'name' | 'type' | 'handler'>) => unknown;
Expand Down Expand Up @@ -49,6 +54,13 @@ export interface CdbPanel {
export function createCdbPanel(options: CdbPanelOptions): CdbPanel {
const directory = dirname(fileURLToPath(import.meta.url));
let disposed = false;
let stopNotifications: (() => void) | undefined;
const notifications = createBrowserControlNotificationController({
onReview() {
throw new Error('Browser approval must run in the viewing tab.');
},
onRevoke: async requestId => activeClient().revokeScope(requestId),
});
let unsubscribe: (() => void) | undefined;
let view: JsonRenderView | undefined;
let client: BrowserControlPanelClient | undefined;
Expand Down Expand Up @@ -81,6 +93,7 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel {
client = options.client();
clientResolved = true;
}
if (context.messages !== undefined) stopNotifications = publishBrowserControlNotifications(notifications, context.messages, options.approvalAction);
const renderer = 'docks' in context ? options.renderer : undefined;
const rpc = context.scope('cdb:panel').rpc;
const initial = emptyState();
Expand All @@ -103,6 +116,7 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel {
value.available = selected !== undefined;
});
view?.update(buildBrowserControlPanelView(broker, renderer?.components));
notifications.update(broker);
};
publish(emptyState());
if (selected === undefined) return;
Expand Down Expand Up @@ -145,6 +159,9 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel {
dispose() {
disposed = true;
generation += 1;
stopNotifications?.();
stopNotifications = undefined;
notifications.dispose();
unsubscribe?.();
unsubscribe = undefined;
view?.dispose();
Expand Down
Loading
Loading