@gjsify/iframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/lib/esm/html-iframe-element.js +131 -0
- package/lib/esm/iframe-widget.js +107 -0
- package/lib/esm/iframe-window-proxy.js +58 -0
- package/lib/esm/index.js +18 -0
- package/lib/esm/message-bridge.js +129 -0
- package/lib/esm/property-symbol.js +8 -0
- package/lib/esm/types/index.js +0 -0
- package/lib/types/html-iframe-element.d.ts +59 -0
- package/lib/types/iframe-widget.d.ts +646 -0
- package/lib/types/iframe-window-proxy.d.ts +51 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/message-bridge.d.ts +47 -0
- package/lib/types/property-symbol.d.ts +3 -0
- package/lib/types/types/index.d.ts +15 -0
- package/package.json +46 -0
- package/src/html-iframe-element.ts +177 -0
- package/src/iframe-widget.ts +158 -0
- package/src/iframe-window-proxy.ts +86 -0
- package/src/index.spec.ts +323 -0
- package/src/index.ts +24 -0
- package/src/message-bridge.ts +175 -0
- package/src/property-symbol.ts +11 -0
- package/src/test.mts +6 -0
- package/src/types/index.ts +19 -0
- package/tmp/.tsbuildinfo +1 -0
- package/tsconfig.json +44 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Tests for @gjsify/iframe — HTMLIFrameElement, IFrameWindowProxy, IFrameWidget
|
|
2
|
+
// Reference: refs/happy-dom/packages/happy-dom/test/nodes/html-iframe-element/
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from '@gjsify/unit';
|
|
5
|
+
|
|
6
|
+
// Import index.ts to trigger side-effect registration (Document.registerElementFactory)
|
|
7
|
+
import { HTMLIFrameElement, IFrameWindowProxy } from './index.js';
|
|
8
|
+
import { Document } from '@gjsify/dom-elements';
|
|
9
|
+
import { HTMLElement, Element, Node } from '@gjsify/dom-elements';
|
|
10
|
+
import { MessageEvent } from '@gjsify/dom-events';
|
|
11
|
+
|
|
12
|
+
export default async () => {
|
|
13
|
+
// -- HTMLIFrameElement DOM properties --
|
|
14
|
+
|
|
15
|
+
await describe('HTMLIFrameElement', async () => {
|
|
16
|
+
await it('should be an instance of HTMLElement, Element, and Node', async () => {
|
|
17
|
+
const iframe = new HTMLIFrameElement();
|
|
18
|
+
expect(iframe instanceof HTMLElement).toBe(true);
|
|
19
|
+
expect(iframe instanceof Element).toBe(true);
|
|
20
|
+
expect(iframe instanceof Node).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await it('should have correct tagName and localName', async () => {
|
|
24
|
+
const iframe = new HTMLIFrameElement();
|
|
25
|
+
expect(iframe.tagName).toBe('IFRAME');
|
|
26
|
+
expect(iframe.localName).toBe('iframe');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await it('should have correct [Symbol.toStringTag]', async () => {
|
|
30
|
+
const iframe = new HTMLIFrameElement();
|
|
31
|
+
expect(Object.prototype.toString.call(iframe)).toBe('[object HTMLIFrameElement]');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// -- src --
|
|
35
|
+
|
|
36
|
+
await it('should get/set src', async () => {
|
|
37
|
+
const iframe = new HTMLIFrameElement();
|
|
38
|
+
expect(iframe.src).toBe('');
|
|
39
|
+
iframe.setAttribute('src', 'https://example.com');
|
|
40
|
+
expect(iframe.src).toBe('https://example.com');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await it('should reflect src to attribute', async () => {
|
|
44
|
+
const iframe = new HTMLIFrameElement();
|
|
45
|
+
iframe.src = 'https://example.com';
|
|
46
|
+
expect(iframe.getAttribute('src')).toBe('https://example.com');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// -- srcdoc --
|
|
50
|
+
|
|
51
|
+
await it('should get/set srcdoc', async () => {
|
|
52
|
+
const iframe = new HTMLIFrameElement();
|
|
53
|
+
expect(iframe.srcdoc).toBe('');
|
|
54
|
+
iframe.setAttribute('srcdoc', '<h1>Hello</h1>');
|
|
55
|
+
expect(iframe.srcdoc).toBe('<h1>Hello</h1>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await it('should reflect srcdoc to attribute', async () => {
|
|
59
|
+
const iframe = new HTMLIFrameElement();
|
|
60
|
+
iframe.srcdoc = '<p>test</p>';
|
|
61
|
+
expect(iframe.getAttribute('srcdoc')).toBe('<p>test</p>');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// -- name --
|
|
65
|
+
|
|
66
|
+
await it('should get/set name', async () => {
|
|
67
|
+
const iframe = new HTMLIFrameElement();
|
|
68
|
+
expect(iframe.name).toBe('');
|
|
69
|
+
iframe.name = 'my-frame';
|
|
70
|
+
expect(iframe.name).toBe('my-frame');
|
|
71
|
+
expect(iframe.getAttribute('name')).toBe('my-frame');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// -- sandbox --
|
|
75
|
+
|
|
76
|
+
await it('should get/set sandbox', async () => {
|
|
77
|
+
const iframe = new HTMLIFrameElement();
|
|
78
|
+
expect(iframe.sandbox).toBe('');
|
|
79
|
+
iframe.sandbox = 'allow-scripts allow-same-origin';
|
|
80
|
+
expect(iframe.sandbox).toBe('allow-scripts allow-same-origin');
|
|
81
|
+
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts allow-same-origin');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// -- allow --
|
|
85
|
+
|
|
86
|
+
await it('should get/set allow', async () => {
|
|
87
|
+
const iframe = new HTMLIFrameElement();
|
|
88
|
+
expect(iframe.allow).toBe('');
|
|
89
|
+
iframe.allow = 'fullscreen; autoplay';
|
|
90
|
+
expect(iframe.allow).toBe('fullscreen; autoplay');
|
|
91
|
+
expect(iframe.getAttribute('allow')).toBe('fullscreen; autoplay');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// -- referrerPolicy --
|
|
95
|
+
|
|
96
|
+
await it('should get/set referrerPolicy', async () => {
|
|
97
|
+
const iframe = new HTMLIFrameElement();
|
|
98
|
+
expect(iframe.referrerPolicy).toBe('');
|
|
99
|
+
iframe.referrerPolicy = 'no-referrer';
|
|
100
|
+
expect(iframe.referrerPolicy).toBe('no-referrer');
|
|
101
|
+
expect(iframe.getAttribute('referrerpolicy')).toBe('no-referrer');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// -- loading --
|
|
105
|
+
|
|
106
|
+
await it('should get/set loading with valid values', async () => {
|
|
107
|
+
const iframe = new HTMLIFrameElement();
|
|
108
|
+
expect(iframe.loading).toBe('eager');
|
|
109
|
+
iframe.loading = 'lazy';
|
|
110
|
+
expect(iframe.loading).toBe('lazy');
|
|
111
|
+
iframe.loading = 'eager';
|
|
112
|
+
expect(iframe.loading).toBe('eager');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await it('should default loading to "eager" for invalid values', async () => {
|
|
116
|
+
const iframe = new HTMLIFrameElement();
|
|
117
|
+
iframe.loading = 'invalid';
|
|
118
|
+
expect(iframe.loading).toBe('eager');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// -- width / height --
|
|
122
|
+
|
|
123
|
+
await it('should get/set width as string', async () => {
|
|
124
|
+
const iframe = new HTMLIFrameElement();
|
|
125
|
+
expect(iframe.width).toBe('');
|
|
126
|
+
iframe.width = '300';
|
|
127
|
+
expect(iframe.width).toBe('300');
|
|
128
|
+
expect(iframe.getAttribute('width')).toBe('300');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await it('should get/set height as string', async () => {
|
|
132
|
+
const iframe = new HTMLIFrameElement();
|
|
133
|
+
expect(iframe.height).toBe('');
|
|
134
|
+
iframe.height = '200';
|
|
135
|
+
expect(iframe.height).toBe('200');
|
|
136
|
+
expect(iframe.getAttribute('height')).toBe('200');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// -- contentWindow / contentDocument --
|
|
140
|
+
|
|
141
|
+
await it('should return null for contentWindow without backing widget', async () => {
|
|
142
|
+
const iframe = new HTMLIFrameElement();
|
|
143
|
+
expect(iframe.contentWindow).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await it('should always return null for contentDocument', async () => {
|
|
147
|
+
const iframe = new HTMLIFrameElement();
|
|
148
|
+
expect(iframe.contentDocument).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// -- Events --
|
|
152
|
+
|
|
153
|
+
await it('should dispatch load event via _onLoad()', async () => {
|
|
154
|
+
const iframe = new HTMLIFrameElement();
|
|
155
|
+
let loaded = false;
|
|
156
|
+
iframe.addEventListener('load', () => { loaded = true; });
|
|
157
|
+
iframe._onLoad();
|
|
158
|
+
expect(loaded).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await it('should dispatch error event via _onError()', async () => {
|
|
162
|
+
const iframe = new HTMLIFrameElement();
|
|
163
|
+
let errored = false;
|
|
164
|
+
iframe.addEventListener('error', () => { errored = true; });
|
|
165
|
+
iframe._onError();
|
|
166
|
+
expect(errored).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await it('should support onload property handler', async () => {
|
|
170
|
+
const iframe = new HTMLIFrameElement();
|
|
171
|
+
let called = false;
|
|
172
|
+
iframe.onload = () => { called = true; };
|
|
173
|
+
iframe._onLoad();
|
|
174
|
+
expect(called).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await it('should support onerror property handler', async () => {
|
|
178
|
+
const iframe = new HTMLIFrameElement();
|
|
179
|
+
let called = false;
|
|
180
|
+
iframe.onerror = () => { called = true; };
|
|
181
|
+
iframe._onError();
|
|
182
|
+
expect(called).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// -- Clone --
|
|
186
|
+
|
|
187
|
+
await it('should clone without widget reference', async () => {
|
|
188
|
+
const iframe = new HTMLIFrameElement();
|
|
189
|
+
iframe.src = 'https://example.com';
|
|
190
|
+
iframe.name = 'test-frame';
|
|
191
|
+
const clone = iframe.cloneNode(false);
|
|
192
|
+
expect(clone instanceof HTMLIFrameElement).toBe(true);
|
|
193
|
+
expect(clone.getAttribute('src')).toBe('https://example.com');
|
|
194
|
+
expect(clone.getAttribute('name')).toBe('test-frame');
|
|
195
|
+
expect(clone.contentWindow).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// -- getSVGDocument --
|
|
199
|
+
|
|
200
|
+
await it('should return null for getSVGDocument()', async () => {
|
|
201
|
+
const iframe = new HTMLIFrameElement();
|
|
202
|
+
expect(iframe.getSVGDocument()).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// -- Document.createElement('iframe') --
|
|
207
|
+
|
|
208
|
+
await describe('Document.registerElementFactory', async () => {
|
|
209
|
+
await it('should create HTMLIFrameElement via document.createElement', async () => {
|
|
210
|
+
// The factory is registered as a side-effect in index.ts
|
|
211
|
+
// which is imported by the test runner
|
|
212
|
+
const doc = new Document();
|
|
213
|
+
const iframe = doc.createElement('iframe');
|
|
214
|
+
expect(iframe instanceof HTMLIFrameElement).toBe(true);
|
|
215
|
+
expect(iframe.tagName).toBe('IFRAME');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// -- IFrameWindowProxy (unit tests without WebView) --
|
|
220
|
+
|
|
221
|
+
await describe('IFrameWindowProxy', async () => {
|
|
222
|
+
await it('should have correct [Symbol.toStringTag]', async () => {
|
|
223
|
+
// Create a minimal mock bridge for unit testing
|
|
224
|
+
const mockBridge = {
|
|
225
|
+
sendToWebView(_data: unknown, _targetOrigin: string) {},
|
|
226
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
227
|
+
};
|
|
228
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
229
|
+
expect(Object.prototype.toString.call(proxy)).toBe('[object IFrameWindowProxy]');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await it('should return globalThis as parent', async () => {
|
|
233
|
+
const mockBridge = {
|
|
234
|
+
sendToWebView(_data: unknown, _targetOrigin: string) {},
|
|
235
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
236
|
+
};
|
|
237
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
238
|
+
expect(proxy.parent).toBe(globalThis);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await it('should return globalThis as top', async () => {
|
|
242
|
+
const mockBridge = {
|
|
243
|
+
sendToWebView(_data: unknown, _targetOrigin: string) {},
|
|
244
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
245
|
+
};
|
|
246
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
247
|
+
expect(proxy.top).toBe(globalThis);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await it('should return self references', async () => {
|
|
251
|
+
const mockBridge = {
|
|
252
|
+
sendToWebView(_data: unknown, _targetOrigin: string) {},
|
|
253
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
254
|
+
};
|
|
255
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
256
|
+
expect(proxy.self).toBe(proxy);
|
|
257
|
+
expect(proxy.window).toBe(proxy);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await it('should report closed status', async () => {
|
|
261
|
+
const mockBridge = {
|
|
262
|
+
sendToWebView(_data: unknown, _targetOrigin: string) {},
|
|
263
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
264
|
+
};
|
|
265
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
266
|
+
expect(proxy.closed).toBe(false);
|
|
267
|
+
proxy._close();
|
|
268
|
+
expect(proxy.closed).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await it('should delegate postMessage to bridge', async () => {
|
|
272
|
+
let sentData: unknown;
|
|
273
|
+
let sentOrigin: string | undefined;
|
|
274
|
+
const mockBridge = {
|
|
275
|
+
sendToWebView(data: unknown, targetOrigin: string) {
|
|
276
|
+
sentData = data;
|
|
277
|
+
sentOrigin = targetOrigin;
|
|
278
|
+
},
|
|
279
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
280
|
+
};
|
|
281
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
282
|
+
proxy.postMessage({ hello: 'world' }, 'https://example.com');
|
|
283
|
+
expect((sentData as any).hello).toBe('world');
|
|
284
|
+
expect(sentOrigin).toBe('https://example.com');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await it('should not send message when closed', async () => {
|
|
288
|
+
let called = false;
|
|
289
|
+
const mockBridge = {
|
|
290
|
+
sendToWebView() { called = true; },
|
|
291
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
292
|
+
};
|
|
293
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
294
|
+
proxy._close();
|
|
295
|
+
proxy.postMessage('test');
|
|
296
|
+
expect(called).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await it('should return location from bridge', async () => {
|
|
300
|
+
const mockBridge = {
|
|
301
|
+
sendToWebView() {},
|
|
302
|
+
getLocation() { return { href: 'https://example.com/page', origin: 'https://example.com' }; },
|
|
303
|
+
};
|
|
304
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
305
|
+
expect(proxy.location.href).toBe('https://example.com/page');
|
|
306
|
+
expect(proxy.location.origin).toBe('https://example.com');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await it('should support addEventListener for message events', async () => {
|
|
310
|
+
const mockBridge = {
|
|
311
|
+
sendToWebView() {},
|
|
312
|
+
getLocation() { return { href: 'about:blank', origin: 'null' }; },
|
|
313
|
+
};
|
|
314
|
+
const proxy = new IFrameWindowProxy(mockBridge as any);
|
|
315
|
+
let received: unknown;
|
|
316
|
+
proxy.addEventListener('message', (event: Event) => {
|
|
317
|
+
received = (event as MessageEvent).data;
|
|
318
|
+
});
|
|
319
|
+
proxy.dispatchEvent(new MessageEvent('message', { data: 'hello' }));
|
|
320
|
+
expect(received).toBe('hello');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// HTMLIFrameElement for GJS — backed by WebKit.WebView
|
|
2
|
+
// Reference: refs/happy-dom/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts
|
|
3
|
+
// Reference: refs/map-editor/packages/message-channel-gjs/ (GJS ↔ WebView communication)
|
|
4
|
+
|
|
5
|
+
export { HTMLIFrameElement } from './html-iframe-element.js';
|
|
6
|
+
export { IFrameWidget } from './iframe-widget.js';
|
|
7
|
+
export { IFrameWindowProxy } from './iframe-window-proxy.js';
|
|
8
|
+
export { MessageBridge } from './message-bridge.js';
|
|
9
|
+
export type { IFrameWidgetOptions, IFrameReadyCallback, IFrameMessageData } from './types/index.js';
|
|
10
|
+
|
|
11
|
+
// Side-effect: register DOM globals on import.
|
|
12
|
+
// Same pattern as @gjsify/dom-elements and @gjsify/canvas2d.
|
|
13
|
+
import { Document } from '@gjsify/dom-elements';
|
|
14
|
+
import { HTMLIFrameElement } from './html-iframe-element.js';
|
|
15
|
+
|
|
16
|
+
// Register so that document.createElement('iframe') works
|
|
17
|
+
Document.registerElementFactory('iframe', () => new HTMLIFrameElement());
|
|
18
|
+
|
|
19
|
+
// Register global constructor
|
|
20
|
+
Object.defineProperty(globalThis, 'HTMLIFrameElement', {
|
|
21
|
+
value: HTMLIFrameElement,
|
|
22
|
+
writable: true,
|
|
23
|
+
configurable: true,
|
|
24
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// MessageBridge for GJS — postMessage bridge between GJS and WebKit.WebView
|
|
2
|
+
// Adapted from refs/map-editor/packages/message-channel-gjs/src/rpc-endpoint.ts
|
|
3
|
+
// Copyright (c) PixelRPG contributors. MIT license.
|
|
4
|
+
// Modifications: Simplified to standard postMessage semantics (no JSON-RPC layer)
|
|
5
|
+
|
|
6
|
+
import Gio from 'gi://Gio?version=2.0';
|
|
7
|
+
import WebKit from 'gi://WebKit?version=6.0';
|
|
8
|
+
import JavaScriptCore from 'gi://JavaScriptCore?version=6.0';
|
|
9
|
+
import { MessageEvent } from '@gjsify/dom-events';
|
|
10
|
+
|
|
11
|
+
// Promisify evaluate_javascript so it returns a Promise in GJS
|
|
12
|
+
Gio._promisify(WebKit.WebView.prototype, 'evaluate_javascript', 'evaluate_javascript_finish');
|
|
13
|
+
|
|
14
|
+
import type { IFrameWindowProxy } from './iframe-window-proxy.js';
|
|
15
|
+
import type { IFrameMessageData } from './types/index.js';
|
|
16
|
+
|
|
17
|
+
const CHANNEL_NAME = 'gjsify-iframe';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bootstrap script injected into every WebView page at document start.
|
|
21
|
+
* Provides the `window.parent.postMessage()` bridge from WebView content back to GJS.
|
|
22
|
+
*
|
|
23
|
+
* The script:
|
|
24
|
+
* 1. Gets the WebKit message handler registered under CHANNEL_NAME
|
|
25
|
+
* 2. Creates a parent proxy with a postMessage() that sends via the WebKit handler
|
|
26
|
+
* 3. Overrides window.parent to point to the proxy
|
|
27
|
+
*/
|
|
28
|
+
const BOOTSTRAP_SCRIPT = `(function() {
|
|
29
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers['${CHANNEL_NAME}'];
|
|
30
|
+
if (!handler) return;
|
|
31
|
+
function bridgePostMessage(data, targetOrigin) {
|
|
32
|
+
handler.postMessage(JSON.stringify({
|
|
33
|
+
data: data,
|
|
34
|
+
targetOrigin: targetOrigin || '*',
|
|
35
|
+
origin: location.origin
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
// In a WebKit.WebView loaded via srcdoc, window.parent === window (no real iframe nesting).
|
|
39
|
+
// window.parent is [LegacyUnforgeable] — cannot be redefined with defineProperty.
|
|
40
|
+
// Instead, override window.postMessage directly. Since window.parent === window,
|
|
41
|
+
// calls to window.parent.postMessage() will use our override.
|
|
42
|
+
var origPostMessage = window.postMessage;
|
|
43
|
+
window.postMessage = function(data, targetOrigin) {
|
|
44
|
+
bridgePostMessage(data, targetOrigin);
|
|
45
|
+
};
|
|
46
|
+
// Also expose on a safe namespace for explicit use
|
|
47
|
+
window.__gjsifyBridge = { postMessage: bridgePostMessage, origPostMessage: origPostMessage };
|
|
48
|
+
})();`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Manages bidirectional postMessage communication between GJS and a WebKit.WebView.
|
|
52
|
+
*
|
|
53
|
+
* Direction 1 — GJS → WebView:
|
|
54
|
+
* Uses webView.evaluate_javascript() to dispatch a MessageEvent on the WebView's window.
|
|
55
|
+
*
|
|
56
|
+
* Direction 2 — WebView → GJS:
|
|
57
|
+
* Bootstrap script overrides window.parent.postMessage to call
|
|
58
|
+
* webkit.messageHandlers[CHANNEL_NAME].postMessage(), which triggers
|
|
59
|
+
* the UserContentManager 'script-message-received' signal in GJS.
|
|
60
|
+
*/
|
|
61
|
+
export class MessageBridge {
|
|
62
|
+
private _webView: WebKit.WebView;
|
|
63
|
+
private _userContentManager: WebKit.UserContentManager;
|
|
64
|
+
private _windowProxy: IFrameWindowProxy | null = null;
|
|
65
|
+
private _currentUri = 'about:blank';
|
|
66
|
+
private _signalId: number | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(webView: WebKit.WebView) {
|
|
69
|
+
this._webView = webView;
|
|
70
|
+
this._userContentManager = webView.get_user_content_manager();
|
|
71
|
+
this._setupReceiver();
|
|
72
|
+
this._injectBootstrapScript();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Connect the IFrameWindowProxy that will receive messages from the WebView */
|
|
76
|
+
setWindowProxy(proxy: IFrameWindowProxy): void {
|
|
77
|
+
this._windowProxy = proxy;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Update current URI (called by IFrameWidget on load-changed) */
|
|
81
|
+
updateUri(uri: string): void {
|
|
82
|
+
this._currentUri = uri;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get current location info for the IFrameWindowProxy */
|
|
86
|
+
getLocation(): { href: string; origin: string } {
|
|
87
|
+
let origin: string;
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(this._currentUri);
|
|
90
|
+
origin = url.origin;
|
|
91
|
+
} catch {
|
|
92
|
+
origin = 'null';
|
|
93
|
+
}
|
|
94
|
+
return { href: this._currentUri, origin };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send a message from GJS to the WebView content.
|
|
99
|
+
* Dispatches a standard MessageEvent on the WebView's window object.
|
|
100
|
+
*/
|
|
101
|
+
sendToWebView(data: unknown, _targetOrigin: string): void {
|
|
102
|
+
const serialized = JSON.stringify(data);
|
|
103
|
+
const origin = JSON.stringify('gjsify');
|
|
104
|
+
// Note: do not pass `source` — WebKit's MessageEvent constructor throws TypeError
|
|
105
|
+
// if source is not a valid MessageEventSource (Window/MessagePort/ServiceWorker)
|
|
106
|
+
const script = `window.dispatchEvent(new MessageEvent('message', { data: JSON.parse(${JSON.stringify(serialized)}), origin: ${origin} }));`;
|
|
107
|
+
|
|
108
|
+
// evaluate_javascript is async in WebKit 6.0 — fire and forget
|
|
109
|
+
this._webView.evaluate_javascript(
|
|
110
|
+
script,
|
|
111
|
+
-1, // length (-1 = null-terminated)
|
|
112
|
+
null, // world name (null = default)
|
|
113
|
+
null, // source URI
|
|
114
|
+
null, // cancellable
|
|
115
|
+
).catch(() => {
|
|
116
|
+
// Ignore errors for fire-and-forget message dispatch
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Clean up signal handlers */
|
|
121
|
+
destroy(): void {
|
|
122
|
+
if (this._signalId !== null) {
|
|
123
|
+
this._userContentManager.disconnect(this._signalId);
|
|
124
|
+
this._signalId = null;
|
|
125
|
+
}
|
|
126
|
+
this._userContentManager.unregister_script_message_handler(CHANNEL_NAME, null);
|
|
127
|
+
this._windowProxy = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set up the receiver for messages coming from the WebView.
|
|
132
|
+
* Registers a script message handler and connects to the signal.
|
|
133
|
+
*/
|
|
134
|
+
private _setupReceiver(): void {
|
|
135
|
+
this._userContentManager.register_script_message_handler(CHANNEL_NAME, null);
|
|
136
|
+
|
|
137
|
+
this._signalId = this._userContentManager.connect(
|
|
138
|
+
`script-message-received::${CHANNEL_NAME}`,
|
|
139
|
+
(_ucm: WebKit.UserContentManager, jsValue: JavaScriptCore.Value) => {
|
|
140
|
+
if (!this._windowProxy) return;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// The bootstrap script sends JSON.stringify({data, targetOrigin, origin})
|
|
144
|
+
// so jsValue is a JSC string. Use to_string() to get the raw JSON.
|
|
145
|
+
const json = jsValue.to_string();
|
|
146
|
+
const envelope: IFrameMessageData = JSON.parse(json);
|
|
147
|
+
|
|
148
|
+
// Dispatch MessageEvent on the IFrameWindowProxy
|
|
149
|
+
const event = new MessageEvent('message', {
|
|
150
|
+
data: envelope.data,
|
|
151
|
+
origin: envelope.origin,
|
|
152
|
+
});
|
|
153
|
+
this._windowProxy.dispatchEvent(event);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error('[IFrame MessageBridge] Error processing message:', error);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Inject the bootstrap script into the WebView so that
|
|
163
|
+
* window.parent.postMessage() bridges back to GJS.
|
|
164
|
+
*/
|
|
165
|
+
private _injectBootstrapScript(): void {
|
|
166
|
+
const script = new WebKit.UserScript(
|
|
167
|
+
BOOTSTRAP_SCRIPT,
|
|
168
|
+
WebKit.UserContentInjectedFrames.ALL_FRAMES,
|
|
169
|
+
WebKit.UserScriptInjectionTime.START,
|
|
170
|
+
null, // allow list (null = all)
|
|
171
|
+
null, // block list (null = none)
|
|
172
|
+
);
|
|
173
|
+
this._userContentManager.add_script(script);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Iframe-specific property symbols
|
|
2
|
+
// Follows pattern from packages/dom/dom-elements/src/property-symbol.ts
|
|
3
|
+
|
|
4
|
+
// IFrameWidget (GTK WebView backing)
|
|
5
|
+
export const iframeWidget = Symbol('iframeWidget');
|
|
6
|
+
|
|
7
|
+
// IFrameWindowProxy (contentWindow)
|
|
8
|
+
export const windowProxy = Symbol('windowProxy');
|
|
9
|
+
|
|
10
|
+
// Loading state
|
|
11
|
+
export const loaded = Symbol('loaded');
|
package/src/test.mts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared interfaces for @gjsify/iframe
|
|
2
|
+
|
|
3
|
+
/** Options passed to IFrameWidget constructor */
|
|
4
|
+
export interface IFrameWidgetOptions {
|
|
5
|
+
/** Enable developer extras (Web Inspector). Default: true */
|
|
6
|
+
enableDeveloperExtras?: boolean;
|
|
7
|
+
/** Enable JavaScript execution in the WebView. Default: true */
|
|
8
|
+
enableJavascript?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Data structure for messages crossing the GJS/WebView boundary */
|
|
12
|
+
export interface IFrameMessageData {
|
|
13
|
+
data: unknown;
|
|
14
|
+
targetOrigin: string;
|
|
15
|
+
origin: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Callback for when the IFrameWidget is ready */
|
|
19
|
+
export type IFrameReadyCallback = (iframe: globalThis.HTMLIFrameElement) => void;
|