|
| 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