CI / Build & Quality Checks (pull_request) Successful in 3m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 18s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m40s
MSC2931: a widget with m.always_on_screen (auto-granted) can send set_always_on_screen; the host now answers it, and a widget that asked keeps running in a small floating window (title, Back to room, close) when the Widgets panel closes or you switch rooms. Back to room returns to it with the panel open on that widget, without reloading it. Moving an iframe reloads it, so widget frames now live in an app-level layer from the start (widgetSessions.ts) and are positioned over the panel's placeholder while docked. A widget that did not ask is torn down as before. One floating widget at a time; it also goes when the widget withdraws, is removed from the room, or on logout. A permission request while floating grants nothing extra. Refs #205 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import {
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
MatrixEventEvent,
|
|
Room,
|
|
RoomEvent,
|
|
RoomEventHandlerMap,
|
|
RoomStateEvent,
|
|
} from 'matrix-js-sdk';
|
|
import {
|
|
Capability,
|
|
ClientWidgetApi,
|
|
IRoomEvent,
|
|
IWidgetApiRequest,
|
|
MatrixCapabilities,
|
|
Widget,
|
|
WidgetApiFromWidgetAction,
|
|
} from 'matrix-widget-api';
|
|
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
|
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
|
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
|
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
|
import { zIndices } from '../../../styles/zIndex';
|
|
|
|
/**
|
|
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
|
|
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
|
|
* Widgets panel or switching rooms.
|
|
*
|
|
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
|
|
* start in one app-level fixed layer. While the panel shows the widget, the
|
|
* frame is positioned over the panel's placeholder (`attach`), so it looks and
|
|
* behaves as before. When the placeholder goes away (`detach`) the widget is
|
|
* torn down, unless it is always-on-screen: then the same live frame moves to
|
|
* a small floating window (see FloatingWidget) until it is closed, the widget
|
|
* says it no longer needs to be on screen, or it is removed from the room.
|
|
*/
|
|
|
|
export type PendingAsk = {
|
|
requests: WidgetPermissionRequest[];
|
|
origin: string;
|
|
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
|
};
|
|
|
|
export type WidgetSession = {
|
|
readonly key: string;
|
|
readonly roomId: string;
|
|
readonly widgetId: string;
|
|
readonly name: string;
|
|
readonly templateUrl: string;
|
|
/** The URL is on this app's own origin: never rendered. */
|
|
readonly blocked: boolean;
|
|
alwaysOnScreen: boolean;
|
|
floating: boolean;
|
|
pendingAsk?: PendingAsk;
|
|
};
|
|
|
|
type Internal = WidgetSession & {
|
|
frame: HTMLDivElement;
|
|
slot: HTMLElement | null;
|
|
raf: number | undefined;
|
|
stop: () => void;
|
|
};
|
|
|
|
/** The floating window's geometry (FloatingWidget draws its title bar above). */
|
|
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
|
|
|
|
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
|
|
|
|
const sessions = new Map<string, Internal>();
|
|
const listeners = new Set<() => void>();
|
|
let version = 0;
|
|
const notify = () => {
|
|
version += 1;
|
|
listeners.forEach((l) => l());
|
|
};
|
|
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
|
|
listeners.add(l);
|
|
return () => listeners.delete(l);
|
|
};
|
|
export const widgetSessionsVersion = (): number => version;
|
|
|
|
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
|
|
`${roomId}|${widgetId}`;
|
|
|
|
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
|
|
|
|
export const floatingWidgetSessions = (): WidgetSession[] =>
|
|
[...sessions.values()].filter((s) => s.floating);
|
|
|
|
let layerEl: HTMLDivElement | undefined;
|
|
const layer = (): HTMLDivElement => {
|
|
if (!layerEl || !layerEl.isConnected) {
|
|
layerEl = document.createElement('div');
|
|
layerEl.id = 'lotus-widget-layer';
|
|
// The layer itself is click-through; only the frames inside take input.
|
|
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
|
|
document.body.append(layerEl);
|
|
}
|
|
return layerEl;
|
|
};
|
|
|
|
const hideFrame = (s: Internal) => {
|
|
s.frame.style.visibility = 'hidden';
|
|
};
|
|
|
|
const placeDocked = (s: Internal) => {
|
|
const slot = s.slot;
|
|
if (!slot) return;
|
|
const r = slot.getBoundingClientRect();
|
|
if (r.width === 0 || r.height === 0) {
|
|
hideFrame(s);
|
|
} else {
|
|
Object.assign(s.frame.style, {
|
|
visibility: 'visible',
|
|
left: `${r.left}px`,
|
|
top: `${r.top}px`,
|
|
width: `${r.width}px`,
|
|
height: `${r.height}px`,
|
|
right: '',
|
|
bottom: '',
|
|
borderRadius: '0',
|
|
boxShadow: 'none',
|
|
zIndex: String(DOCKED_Z),
|
|
});
|
|
}
|
|
s.raf = requestAnimationFrame(() => placeDocked(s));
|
|
};
|
|
|
|
const placeFloating = (s: Internal) => {
|
|
const f = FLOATING_WIDGET;
|
|
Object.assign(s.frame.style, {
|
|
visibility: 'visible',
|
|
left: '',
|
|
top: '',
|
|
right: `${f.right}px`,
|
|
bottom: `${f.bottom}px`,
|
|
width: `${f.width}px`,
|
|
height: `${f.height}px`,
|
|
borderRadius: '0 0 8px 8px',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
|
zIndex: String(zIndices.floatingWidget),
|
|
});
|
|
};
|
|
|
|
const stopPlacing = (s: Internal) => {
|
|
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
|
|
s.raf = undefined;
|
|
};
|
|
|
|
export const destroyWidgetSession = (key: string): void => {
|
|
const s = sessions.get(key);
|
|
if (!s) return;
|
|
sessions.delete(key);
|
|
stopPlacing(s);
|
|
s.stop();
|
|
s.frame.remove();
|
|
notify();
|
|
};
|
|
|
|
export const destroyAllWidgetSessions = (): void => {
|
|
[...sessions.keys()].forEach(destroyWidgetSession);
|
|
};
|
|
|
|
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
|
|
export function attachWidgetSession(
|
|
mx: MatrixClient,
|
|
room: Room,
|
|
widget: Widget,
|
|
slot: HTMLElement,
|
|
): WidgetSession {
|
|
const key = widgetSessionKey(room.roomId, widget.id);
|
|
let s = sessions.get(key);
|
|
if (s && s.templateUrl !== widget.templateUrl) {
|
|
destroyWidgetSession(key);
|
|
s = undefined;
|
|
}
|
|
if (!s) s = startSession(mx, room, widget, key);
|
|
stopPlacing(s);
|
|
s.slot = slot;
|
|
s.floating = false;
|
|
if (!s.blocked) placeDocked(s);
|
|
notify();
|
|
return s;
|
|
}
|
|
|
|
/** The panel stopped showing the widget: float it, or tear it down. */
|
|
export function detachWidgetSession(key: string, slot: HTMLElement): void {
|
|
const s = sessions.get(key);
|
|
if (!s || s.slot !== slot) return;
|
|
s.slot = null;
|
|
stopPlacing(s);
|
|
if (!s.alwaysOnScreen || s.blocked) {
|
|
destroyWidgetSession(key);
|
|
return;
|
|
}
|
|
// One floating widget at a time: a newer one replaces an older one.
|
|
floatingWidgetSessions()
|
|
.filter((o) => o.key !== key)
|
|
.forEach((o) => destroyWidgetSession(o.key));
|
|
// Nobody is there to answer a permission prompt any more.
|
|
s.pendingAsk?.resolve(new Set(), false);
|
|
s.floating = true;
|
|
placeFloating(s);
|
|
notify();
|
|
}
|
|
|
|
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
|
|
const frame = document.createElement('div');
|
|
Object.assign(frame.style, {
|
|
position: 'fixed',
|
|
pointerEvents: 'auto',
|
|
overflow: 'hidden',
|
|
visibility: 'hidden',
|
|
});
|
|
|
|
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
|
|
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
|
|
const s: Internal = {
|
|
key,
|
|
roomId: room.roomId,
|
|
widgetId: widget.id,
|
|
name: widget.name || 'Widget',
|
|
templateUrl: widget.templateUrl,
|
|
blocked,
|
|
alwaysOnScreen: false,
|
|
floating: false,
|
|
frame,
|
|
slot: null,
|
|
raf: undefined,
|
|
stop: () => undefined,
|
|
};
|
|
sessions.set(key, s);
|
|
if (blocked) return s;
|
|
|
|
// Security: never render a same-origin widget with allow-same-origin (a
|
|
// same-origin frame could break out of the sandbox against our own origin).
|
|
const iframe = document.createElement('iframe');
|
|
iframe.title = widget.name || 'Widget';
|
|
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
|
iframe.allow = 'autoplay; clipboard-write;';
|
|
iframe.src = completeUrl;
|
|
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
|
|
frame.append(iframe);
|
|
layer().append(frame);
|
|
|
|
let origin = completeUrl;
|
|
try {
|
|
origin = new URL(completeUrl).host;
|
|
} catch {
|
|
// isWidgetUrlSafe already rejected unparsable URLs
|
|
}
|
|
let cancelAsk: (() => void) | undefined;
|
|
const driver = new GeneralWidgetDriver(
|
|
mx,
|
|
room.roomId,
|
|
{ id: widget.id, url: widget.templateUrl },
|
|
(requests) =>
|
|
new Promise((resolve) => {
|
|
let settled = false;
|
|
const done = (allowed: Set<Capability>, remember: boolean) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cancelAsk = undefined;
|
|
s.pendingAsk = undefined;
|
|
notify();
|
|
resolve({ allowed, remember });
|
|
};
|
|
cancelAsk = () => done(new Set(), false);
|
|
// A floating widget has nobody to ask: grant nothing extra.
|
|
if (s.floating) {
|
|
done(new Set(), false);
|
|
return;
|
|
}
|
|
s.pendingAsk = { requests, origin, resolve: done };
|
|
notify();
|
|
}),
|
|
);
|
|
const clientApi = new ClientWidgetApi(widget, iframe, driver);
|
|
// Only messages from this widget's own frame and origin are handled, so a
|
|
// widget can't impersonate another one (or the call).
|
|
restrictWidgetMessages(clientApi, iframe, widget.origin);
|
|
clientApi.setViewedRoomId(room.roomId);
|
|
|
|
// MSC2931: the widget asks to stay visible when the user navigates away.
|
|
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
|
|
ev.preventDefault();
|
|
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
|
clientApi.transport.reply(ev.detail, {
|
|
error: { message: 'Missing capability' },
|
|
});
|
|
return;
|
|
}
|
|
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
|
|
s.alwaysOnScreen = value;
|
|
clientApi.transport.reply(ev.detail, {});
|
|
// Floating only because it asked: once it no longer needs to be, go.
|
|
if (!value && s.floating) destroyWidgetSession(key);
|
|
else notify();
|
|
};
|
|
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
|
|
|
// Live room events for widgets that were allowed to see them
|
|
// (ClientWidgetApi drops anything the widget has no capability for).
|
|
const feed = (ev: MatrixEvent) => {
|
|
if (ev.getRoomId() !== room.roomId) return;
|
|
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
|
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
};
|
|
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
|
if (toStart || removed || !data?.liveEvent) return;
|
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
|
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
|
else feed(ev);
|
|
};
|
|
const onState = (ev: MatrixEvent) => {
|
|
if (ev.getRoomId() !== room.roomId) return;
|
|
// The widget was removed from the room while it was floating.
|
|
if (
|
|
s.floating &&
|
|
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
|
|
ev.getStateKey() === widget.id &&
|
|
!ev.getContent()?.url
|
|
) {
|
|
destroyWidgetSession(key);
|
|
return;
|
|
}
|
|
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
};
|
|
mx.on(RoomEvent.Timeline, onTimeline);
|
|
mx.on(RoomStateEvent.Events, onState);
|
|
|
|
s.stop = () => {
|
|
mx.off(RoomEvent.Timeline, onTimeline);
|
|
mx.off(RoomStateEvent.Events, onState);
|
|
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
|
cancelAsk?.();
|
|
clientApi.stop();
|
|
};
|
|
return s;
|
|
}
|