Skip to content

Commit 940fb0f

Browse files
sunliangqinCopilot
andcommitted
Add OpenLinkInSidePanelMode test harness page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
0 parents  commit 940fb0f

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

index.html

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
<!DOCTYPE html>
2+
<!--
3+
OpenLinkInSidePanelMode test harness
4+
====================================
5+
This standalone page emulates the Google AI-Mode (AIM) guest that normally
6+
runs inside the <webview> of chrome://contextual-tasks. Clicking the link
7+
posts a serialized `lens.AimToClientMessage` proto containing an
8+
`open_link_in_side_panel_mode` event to the parent WebUI, which routes it to
9+
ContextualTasksPageHandler::OnWebviewMessage -> ui_service_->OnThreadLinkClicked().
10+
11+
Wire path (see .vscode/notes.md). NOTE: the arrows below use '=' instead of
12+
'-' so they cannot accidentally terminate this HTML comment early:
13+
this page (guest) ==window.postMessage(ArrayBuffer)==> post_message_handler.ts
14+
==mojo onWebviewMessage(bytes)======> ContextualTasksPageHandler (C++)
15+
==OnThreadLinkClicked(url,...)=======> ContextualTasksUiService
16+
17+
IMPORTANT — reaching the WebUI from inside a <webview> guest:
18+
A <webview> guest is an isolated frame tree, so window.parent === window
19+
(the guest is its own top frame). You CANNOT reach the embedder WebUI via
20+
window.parent — you must post to the embedder window captured from the
21+
.source of a message it sends you (the handshake ping). This page does that
22+
(mirroring Chrome's own injected content script in app.ts).
23+
Separately, post_message_handler.ts drops any message where
24+
`event.origin !== this.targetOrigin_` (targetOrigin_ = the origin the
25+
<webview> committed to, i.e. http://localhost:3000), so this page must be
26+
loaded AS the webview guest for its messages to be accepted.
27+
28+
How to load it as the guest:
29+
1. Start the HTTP server in this folder (serves this file at
30+
http://localhost:3000 for every path, including /search):
31+
cd .vscode/test-server && npm install && npm start
32+
2. util.cc points AIM search at http://localhost:3000/search?q=...&udm=50,
33+
so launch Chrome with the CT test surface:
34+
chrome.exe ^
35+
--allow-signed-out-for-contextual-tasks-testing ^
36+
--enable-features="ContextualTasks,ContextualTasksForceEntryPointEligibility" ^
37+
--disable-features="AimTriggeredThreadLinks,ContextualTasksRearchitecture,ContextualTasksSidePanelRearchitecture,AimCoBrowseEligibilityCheckEnabled,AimServerEligibilityEnabled"
38+
3. Enter AI Mode (omnibox) or open chrome://contextual-tasks/ . The webview
39+
commits to http://localhost:3000/... so targetOrigin_ becomes
40+
http://localhost:3000 and messages from this page are accepted.
41+
4. Click "Send OpenLinkInSidePanelMode".
42+
43+
(The URL you send MUST be http/https — C++ rejects other schemes.)
44+
-->
45+
<html lang="en">
46+
<head>
47+
<meta charset="utf-8">
48+
<meta name="viewport" content="width=device-width, initial-scale=1">
49+
<title>AI Mode</title>
50+
<style>
51+
:root { color-scheme: light dark; }
52+
body {
53+
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
54+
box-sizing: border-box; width: 100%; min-height: 100vh; margin: 0; padding: 24px;
55+
display: flex; flex-direction: column;
56+
}
57+
h1 { font-size: 18px; margin: 0 0 16px; }
58+
fieldset { border: 1px solid rgba(128,128,128,.4); border-radius: 8px; margin: 0 0 16px; padding: 16px; }
59+
/* Keeps the log panel pinned to the bottom of the viewport. */
60+
fieldset.logs { margin: auto 0 0; }
61+
legend { padding: 0 6px; font-weight: 600; }
62+
label { display: block; font-weight: 600; margin-bottom: 6px; }
63+
button {
64+
display: inline-block; margin: 0 8px 16px 0; padding: 10px 18px; font-size: 14px; font-weight: 600;
65+
color: #fff; background: #1a73e8; border: none; border-radius: 20px; text-decoration: none; cursor: pointer;
66+
}
67+
button:hover { background: #1666c9; }
68+
code { background: rgba(128,128,128,.15); padding: 1px 5px; border-radius: 4px; }
69+
#log {
70+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; white-space: pre-wrap;
71+
word-break: break-all; background: rgba(128,128,128,.1); border: 1px solid rgba(128,128,128,.3);
72+
border-radius: 6px; padding: 12px; height: 240px; overflow: auto; margin: 0;
73+
}
74+
.row { display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-bottom: 8px; }
75+
</style>
76+
</head>
77+
<body>
78+
<h1 id="heading">AI Mode</h1>
79+
80+
<div class="presets">
81+
<button type="button" data-url="https://www.example.com/">example.com</button>
82+
<button type="button" data-url="https://en.wikipedia.org/wiki/Chromium#:~:text=side%20panel">wikipedia.org</button>
83+
</div>
84+
85+
<fieldset class="logs">
86+
<legend>Debug Logs</legend>
87+
<div class="row">
88+
<button id="clearLog" type="button">Clear</button>
89+
</div>
90+
<pre id="log"></pre>
91+
</fieldset>
92+
93+
<script>
94+
'use strict';
95+
96+
// ---------------------------------------------------------------------------
97+
// Minimal protobuf wire-format encoder (only length-delimited fields needed).
98+
// ---------------------------------------------------------------------------
99+
100+
// Encode an unsigned integer as a base-128 varint.
101+
function encodeVarint(value) {
102+
const out = [];
103+
while (value > 0x7f) {
104+
out.push((value & 0x7f) | 0x80);
105+
value = Math.floor(value / 128);
106+
}
107+
out.push(value & 0x7f);
108+
return out;
109+
}
110+
111+
// Length-delimited field (wire type 2): tag = (fieldNumber << 3) | 2.
112+
function lenField(fieldNumber, payloadBytes) {
113+
const tag = (fieldNumber << 3) | 2;
114+
return [tag, ...encodeVarint(payloadBytes.length), ...payloadBytes];
115+
}
116+
117+
function utf8(str) {
118+
return Array.from(new TextEncoder().encode(str));
119+
}
120+
121+
// OpenLinkInSidePanelMode { string url = 1; }
122+
function encodeOpenLink(url) {
123+
return lenField(1, utf8(url));
124+
}
125+
126+
// AimToClientMessage { OpenLinkInSidePanelMode open_link_in_side_panel_mode = 14; }
127+
function encodeAimOpenLink(url) {
128+
return lenField(14, encodeOpenLink(url));
129+
}
130+
131+
// AimToClientMessage { HandshakeResponse handshake_response = 1; }
132+
// Empty HandshakeResponse is enough: C++ only checks has_handshake_response().
133+
function encodeHandshakeResponse() {
134+
return lenField(1, []);
135+
}
136+
137+
// ---------------------------------------------------------------------------
138+
// Messaging
139+
// ---------------------------------------------------------------------------
140+
141+
// In a <webview> guest, window.parent === window (the guest is the top of its
142+
// own isolated frame tree), so the embedder (the chrome://contextual-tasks
143+
// WebUI) is NOT window.parent. It is only reachable via the .source of a
144+
// message it sends us (the handshake ping). Capture it here — this mirrors what
145+
// Chrome's own injected content script does (app.ts setupWebviewRequestOverrides).
146+
let embedderWindow = null; // the WebUI window, captured from event.source.
147+
let embedderOrigin = ''; // the WebUI origin, captured from event.origin.
148+
let handshakeReplied = false;
149+
150+
const logEl = document.getElementById('log');
151+
function log(msg) {
152+
const t = new Date().toISOString().substr(11, 12);
153+
logEl.textContent += `[${t}] ${msg}\n`;
154+
logEl.scrollTop = logEl.scrollHeight;
155+
}
156+
function resetLog() {
157+
logEl.textContent = '';
158+
}
159+
160+
// ---------------------------------------------------------------------------
161+
// Minimal protobuf wire-format DECODER (for the log). Maps the top-level oneof
162+
// field back to its message name from aim_communication.proto and pulls out the
163+
// few payloads worth showing (url, handshake capabilities).
164+
// ---------------------------------------------------------------------------
165+
166+
// this guest -> parent WebUI (AimToClientMessage): the messages we SEND.
167+
const AIM_TO_CLIENT = {
168+
1: 'handshake_response', 2: 'hide_input', 3: 'restore_input',
169+
4: 'enter_basic_mode', 5: 'exit_basic_mode', 7: 'update_thread_context_library',
170+
8: 'notify_zero_state_rendered', 9: 'set_chrome_desktop_input_plate_configuration',
171+
10: 'inject_input', 11: 'remove_injected_input', 12: 'unlock_input',
172+
13: 'lock_input', 14: 'open_link_in_side_panel_mode', 16: 'update_input_plate_state',
173+
};
174+
// parent WebUI -> this guest (ClientToAimMessage): the messages we RECEIVE.
175+
const CLIENT_TO_AIM = {
176+
1: 'handshake_ping', 2: 'submit_query', 3: 'open_threads_view',
177+
4: 'set_cobrowsing_display_mode', 5: 'injected_input_update',
178+
};
179+
const FEATURE_CAPABILITY = {
180+
0: 'DEFAULT', 15: 'OPEN_THREADS_VIEW', 17: 'COBROWSING_DISPLAY_CONTROL',
181+
18: 'THREAD_CONTEXT_LIBRARY', 20: 'NOTIFY_ZERO_STATE_RENDERED',
182+
24: 'SET_CHROME_DESKTOP_INPUT_PLATE_CONFIGURATION', 25: 'UNLOCK_INPUT',
183+
26: 'LOCK_INPUT', 31: 'OPEN_LINK_IN_SIDE_PANEL_MODE',
184+
};
185+
186+
// Read a base-128 varint; returns [value, nextPos]. Uses * (not <<) so values
187+
// past 32 bits stay correct.
188+
function readVarint(bytes, pos) {
189+
let result = 0, scale = 1;
190+
for (;;) {
191+
const b = bytes[pos++];
192+
result += (b & 0x7f) * scale;
193+
if ((b & 0x80) === 0) return [result, pos];
194+
scale *= 128;
195+
}
196+
}
197+
198+
// Split a buffer into raw protobuf fields {field, wire, value}. value is a
199+
// number for varints, a Uint8Array for length-delimited/fixed fields.
200+
function readFields(bytes) {
201+
const fields = [];
202+
let pos = 0;
203+
while (pos < bytes.length) {
204+
const [tag, p1] = readVarint(bytes, pos);
205+
pos = p1;
206+
const field = Math.floor(tag / 8), wire = tag & 7;
207+
if (wire === 0) { // varint
208+
const [v, p2] = readVarint(bytes, pos); pos = p2;
209+
fields.push({ field, wire, value: v });
210+
} else if (wire === 2) { // length-delimited
211+
const [len, p2] = readVarint(bytes, pos);
212+
fields.push({ field, wire, value: bytes.slice(p2, p2 + len) });
213+
pos = p2 + len;
214+
} else if (wire === 5) { // 32-bit
215+
fields.push({ field, wire, value: bytes.slice(pos, pos + 4) }); pos += 4;
216+
} else if (wire === 1) { // 64-bit
217+
fields.push({ field, wire, value: bytes.slice(pos, pos + 8) }); pos += 8;
218+
} else { // group / unknown: stop
219+
break;
220+
}
221+
}
222+
return fields;
223+
}
224+
225+
// Decode a packed repeated FeatureCapability (capabilities = 1) into names.
226+
function capabilityList(payload) {
227+
const capField = readFields(payload).find(x => x.field === 1 && x.wire === 2);
228+
if (!capField) return '';
229+
const caps = [];
230+
let pos = 0;
231+
while (pos < capField.value.length) {
232+
const [v, p] = readVarint(capField.value, pos); pos = p;
233+
caps.push(FEATURE_CAPABILITY[v] || `#${v}`);
234+
}
235+
return `capabilities: [${caps.join(', ')}]`;
236+
}
237+
238+
// Render the payload of a single known message field.
239+
function describePayload(name, f) {
240+
if (f.wire !== 2) return `: ${f.value}`; // scalar
241+
if (f.value.length === 0) return ' { }';
242+
if (name === 'open_link_in_side_panel_mode') {
243+
const url = readFields(f.value).find(x => x.field === 1 && x.wire === 2);
244+
return ` { url: "${url ? new TextDecoder().decode(url.value) : ''}" }`;
245+
}
246+
if (name === 'handshake_ping' || name === 'handshake_response') {
247+
const caps = capabilityList(f.value);
248+
return caps ? ` { ${caps} }` : ' { }';
249+
}
250+
return ` { ${f.value.length} bytes }`;
251+
}
252+
253+
// Turn a serialized oneof message into a readable one-liner.
254+
// dir 'in' = ClientToAimMessage (RECV); dir 'out' = AimToClientMessage (SENT).
255+
function describeMessage(bytes, dir) {
256+
// Our encoders return a plain Array; coerce so nested slices are Uint8Arrays
257+
// and TextDecoder().decode() (used for the url) does not throw.
258+
bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
259+
const names = dir === 'in' ? CLIENT_TO_AIM : AIM_TO_CLIENT;
260+
const typeName = dir === 'in' ? 'ClientToAimMessage' : 'AimToClientMessage';
261+
const fields = readFields(bytes);
262+
if (!fields.length) return `${typeName} { }`;
263+
const body = fields
264+
.map(f => `${names[f.field] || `field#${f.field}`}${describePayload(names[f.field] || '', f)}`)
265+
.join(', ');
266+
return `${typeName} { ${body} }`;
267+
}
268+
269+
function postToEmbedder(byteArray) {
270+
if (!embedderWindow) {
271+
log('CANNOT SEND — no embedder captured yet. This page must be loaded as ' +
272+
'the chrome://contextual-tasks <webview> guest; the embedder window is ' +
273+
'captured from the first message it sends us (the handshake ping). If ' +
274+
'you see no "RECV" lines below, the page is not embedded / the nav was ' +
275+
'not intercepted.');
276+
return 0;
277+
}
278+
const buf = new Uint8Array(byteArray).buffer;
279+
embedderWindow.postMessage(buf, embedderOrigin || '*');
280+
return buf.byteLength;
281+
}
282+
283+
function sendOpenLink(url) {
284+
if (!/^https?:\/\//i.test(url)) {
285+
log(`SKIPPED — "${url}" is not http/https; C++ (OnWebviewMessage) rejects it.`);
286+
return;
287+
}
288+
const bytes = encodeAimOpenLink(url);
289+
if (!postToEmbedder(bytes)) {
290+
return;
291+
}
292+
log(`SENT ${describeMessage(bytes, 'out')} (${bytes.length} bytes)`);
293+
}
294+
295+
// Receive handshake ping (and any other client->AIM message) from the WebUI.
296+
window.addEventListener('message', (event) => {
297+
// Capture the embedder (WebUI) window from the first cross-window message.
298+
// The `event.source !== window` guard filters out our own self-posts, exactly
299+
// like Chrome's injected content script does.
300+
if (!embedderWindow && event.source && event.source !== window) {
301+
embedderWindow = event.source;
302+
embedderOrigin = event.origin;
303+
log(`Embedder captured — origin ${embedderOrigin || '(opaque)'}`);
304+
}
305+
306+
let bytes = null;
307+
if (event.data instanceof ArrayBuffer) {
308+
bytes = new Uint8Array(event.data);
309+
} else if (event.data instanceof Uint8Array) {
310+
bytes = event.data;
311+
}
312+
313+
if (!bytes) {
314+
log(`RECV non-binary message from ${event.origin}: ${JSON.stringify(event.data)}`);
315+
return;
316+
}
317+
318+
const decoded = describeMessage(bytes, 'in');
319+
log(`RECV ${decoded} (${bytes.length} bytes)`);
320+
321+
// The WebUI repeatedly posts a ClientToAimMessage{handshake_ping} until we
322+
// acknowledge. Reply once so the bridge marks the handshake complete.
323+
if (!handshakeReplied) {
324+
handshakeReplied = true;
325+
const hs = encodeHandshakeResponse();
326+
postToEmbedder(hs);
327+
log(`SENT ${describeMessage(hs, 'out')} (${hs.length} bytes) — handshake complete.`);
328+
}
329+
});
330+
331+
// ---------------------------------------------------------------------------
332+
// Wiring
333+
// ---------------------------------------------------------------------------
334+
335+
for (const button of document.querySelectorAll('.presets button[data-url]')) {
336+
button.addEventListener('click', () => { sendOpenLink(button.dataset.url); });
337+
}
338+
339+
document.getElementById('clearLog').addEventListener('click', resetLog);
340+
341+
// The guest is loaded as http://localhost:3000/search?q=...&udm=50 (see
342+
// util.cc), so mirror the AIM search query in the heading and tab title.
343+
const query = new URLSearchParams(location.search).get('q');
344+
const heading = query ? `AI Mode: ${query}` : 'AI Mode';
345+
document.title = heading;
346+
document.getElementById('heading').textContent = heading;
347+
348+
log('Ready. Waiting for the WebUI handshake ping (which reveals the embedder ' +
349+
'window via event.source). Once it arrives, click a button to send.');
350+
</script>
351+
</body>
352+
</html>

0 commit comments

Comments
 (0)