From 07d21a3109799c84ec3b517f968b43bf56cf6187 Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Tue, 27 Jan 2026 22:12:02 -0800 Subject: [PATCH 1/3] clearRect, not fill with transparent --- src/web/js/trove/world.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/web/js/trove/world.js b/src/web/js/trove/world.js index 7eded91a..f4a1f0d0 100644 --- a/src/web/js/trove/world.js +++ b/src/web/js/trove/world.js @@ -532,8 +532,7 @@ } var ctx = reusableCanvas.getContext("2d"); ctx.save(); - ctx.fillStyle = "rgba(255,255,255,0)"; - ctx.fillRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height); ctx.restore(); theImage.render(ctx, 0, 0); success([toplevelNode, reusableCanvasNode]); From 6e58236a94f178a940e45d9f4cc4563465612db1 Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Fri, 30 Jan 2026 13:18:34 -0800 Subject: [PATCH 2/3] [ai-assisted] Work around COOP issues with BroadcastChannel and localStorage polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @schanzer and I were on a call with a school that was getting bad login behavior (blank index page after successfully entering Google credentials, blank definitions area when logged in [but shared files would load; Google connection the issue]). It seems like GoGuardian is to blame. In their dev tools we saw “Cross-Origin-Opener-Policy would block the window.opener call” in the gapi library. My guess/investigation is that some of the communication between the login popup and the home page was failing. I was able to partially reproduce this myself by setting Cross-Origin-Opener-Policy in Chrome devtools and by explicitly adding this to server.js: app.use(function(req, res, next) { res.setHeader("Cross-Origin-Opener-Policy", "noopener-allow-popups"); next(); }); (“Partially” because I got a spinning “waiting for login” with the failure rather than blank, but I'm making a [big?] assumption that these issues are similar enough that this is a good idea.) I think the change here is a strict improvement anyway and could get around some annoying same-origin issues. This says [ai-assisted] because I asked Claude for help, transcript here: ❯ OK here's a debugging issue. We have a school with GoGuardian installed trying to use Pyret (in particular the signin from the index dashboard page in code.pyret.org/src/web/). The login popup opens, Google Login apparently succeeds. However, the main page shows as blank. From debugging (over Zoom, with a non-expert tech person navigating dev tools), it looks like something (I'm tempted to blame GoGuardian), may be inserting Cross-Origin-Opener-Polic headers. We see the error message "Cross-Origin-Opener-Policy would block the window.opener call", pointing to a line number inside the Google Client library. I have a few ideas, but I'd like to hear more and have mine evaluated - Try to do something other than window.opener, like setting something in localStorage and polling for it - Learn more about what GoGuardian is doing (this has been really hard to do). Is there a way to install GoGuardian in a test mode on a dev machine without installing it in some institution-wide way? - Try to use a different Google API for login – this is a bit fraught because we have 10s of thousands of existing users who are all happily logging in, so I'm very wary of breaking something else I've been able to reproduce the same console error message by overriding headers with Cross-Origin-Opener-Policy: noopener-allow-popups in Chrome devtools on the live https://code.pyret.org site. However, the login apparently succeeds when I do that (I'm not sure I'm exactly reproducing everything GoGuardian does). I'd welcome ideas, successful searches of similar issues, feedback on my ideas, and so on ⏺ This is a well-documented class of problem. Let me do some research on both the current Pyret implementation and known solutions for COOP + Google Sign-In conflicts. ⏺ 2 agents finished (ctrl+o to expand) ├─ Task (Research COOP Google Sign-In issues) · 12 tool uses · 22.6k tokens │ ⎿  Done └─ Explore (Explore Pyret login implementation) · 33 tool uses · 57.1k tokens ⎿  Done ⏺ This is very helpful research. Let me synthesize the findings and evaluate your ideas. Current Implementation Analysis Your auth flow in code.pyret.org/src/web/js/google-apis/api-wrapper.js:47-77 uses a popup that communicates via postMessage: // close.html does this: window.opener.postMessage("done", document.location.origin); window.close(); This is the exact line that breaks when COOP headers sever window.opener. --- Evaluation of Your Ideas 1. localStorage + polling (or BroadcastChannel) Verdict: Good practical workaround This is the right direction. BroadcastChannel is even better than polling: // In close.html (popup) localStorage.setItem('pyret_auth_complete', Date.now()); const channel = new BroadcastChannel('pyret_auth'); channel.postMessage({ type: 'auth_complete' }); window.close(); // In api-wrapper.js (opener) const channel = new BroadcastChannel('pyret_auth'); channel.onmessage = (e) => { if (e.data.type === 'auth_complete') { resolve(reauth(true, useFullScopes)); } }; // Fallback: also listen for storage event window.addEventListener('storage', (e) => { if (e.key === 'pyret_auth_complete') { ... } }); Pros: Works regardless of COOP, same-origin so no security issues, minimal code change Cons: Need to handle cleanup, slightly different timing characteristics 2. Learn more about GoGuardian Verdict: Low ROI GoGuardian runs as a Chrome extension + proxy in managed Chromebook environments. You can't easily install it for testing without institutional management. Their release notes mention they've been working on header handling issues, but you can't control what they do. Better approach: Your DevTools header override is actually a reasonable approximation for testing. You could also test with a simple extension that injects COOP headers. 3. Different Google API Verdict: Actually the best long-term solution You're currently using the deprecated gapi client library (apis.google.com/js/client.js). Google is actively migrating to Google Identity Services (GIS) with FedCM support. Key insight from research: GIS with FedCM enabled renders authentication UI directly in the browser—no popup communication needed at all. This completely sidesteps the COOP problem. Google's migration timeline: April 2025 onward, GIS is auto-migrating to FedCM. --- Recommended Approach (Layered) Short-term fix (days): BroadcastChannel fallback Modify close.html and api-wrapper.js to use BroadcastChannel with postMessage as fallback: // close.html - try both methods if (window.opener) { window.opener.postMessage("done", document.location.origin); } // Always also use BroadcastChannel (works even if opener is null) const channel = new BroadcastChannel('pyret_auth'); channel.postMessage({ type: 'done' }); channel.close(); window.close(); Medium-term (weeks): Migrate to Google Identity Services This is worth doing anyway since gapi is deprecated. GIS has specific COOP guidance: - With FedCM enabled: No COOP changes needed - Without FedCM: Requires Cross-Origin-Opener-Policy: same-origin-allow-popups Also consider: Redirect flow option For problematic environments, offer a redirect-based auth flow instead of popup. Your server-side OAuth (/login, /oauth/callback) already supports this—you'd just change the redirect parameter to return to the editor instead of close.html. --- Testing GoGuardian-like behavior Create a simple test extension: // manifest.json { "manifest_version": 3, "name": "COOP Test", "version": "1.0", "permissions": ["declarativeNetRequest"], "declarative_net_request": { "rule_resources": [{ "id": "rules", "enabled": true, "path": "rules.json" }] } } // rules.json [{ "id": 1, "priority": 1, "action": { "type": "modifyHeaders", "responseHeaders": [{ "header": "Cross-Origin-Opener-Policy", "operation": "set", "value": "same-origin" }] }, "condition": { "urlFilter": "*://code.pyret.org/*", "resourceTypes": ["main_frame"] } }] This will let you reliably reproduce the issue locally. --- Key Files to Modify File: code.pyret.org/src/web/close.html Change: Add BroadcastChannel alongside postMessage ──────────────────────────────────────── File: code.pyret.org/src/web/js/google-apis/api-wrapper.js:47-77 Change: Listen on BroadcastChannel in reauth() ──────────────────────────────────────── File: code.pyret.org/src/web/editor.html Change: Eventually: migrate from gapi to GIS Want me to draft the BroadcastChannel changes as a concrete starting point? ✻ Crunched for 3m 10s ❯ yes --- src/web/close.html | 35 ++++++++++++++- src/web/js/google-apis/api-wrapper.js | 61 ++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/web/close.html b/src/web/close.html index fcaf23f0..738b9468 100644 --- a/src/web/close.html +++ b/src/web/close.html @@ -2,8 +2,39 @@ diff --git a/src/web/js/google-apis/api-wrapper.js b/src/web/js/google-apis/api-wrapper.js index abcdda5f..7d4c21ed 100644 --- a/src/web/js/google-apis/api-wrapper.js +++ b/src/web/js/google-apis/api-wrapper.js @@ -51,15 +51,64 @@ function reauth(immediate, useFullScopes) { if(useFullScopes) { path += "&scopes=full"; } - // Need to do a login to get a cookie for this user; do it in a popup - window.addEventListener('message', function(e) { + + // Track whether we've already resolved to avoid double-resolution + var resolved = false; + function resolveOnce() { + if (!resolved) { + resolved = true; + cleanup(); + d.resolve(reauth(true, useFullScopes)); + } + } + + // Cleanup function to remove all listeners + var channel = null; + function cleanup() { + window.removeEventListener('message', messageHandler); + window.removeEventListener('storage', storageHandler); + if (channel) { + try { channel.close(); } catch (e) {} + } + } + + // Method 1: Traditional postMessage (works when COOP allows window.opener) + function messageHandler(e) { // e.domain appears to not be defined in Firefox if ((e.domain || e.origin) === document.location.origin) { - d.resolve(reauth(true, useFullScopes)); - } else { - d.resolve(null); + resolveOnce(); } - }); + } + window.addEventListener('message', messageHandler); + + // Method 2: BroadcastChannel (works even when COOP severs window.opener) + // This is the fallback for environments like GoGuardian that inject COOP headers + if (typeof BroadcastChannel !== 'undefined') { + try { + channel = new BroadcastChannel('pyret_auth'); + channel.onmessage = function(e) { + if (e.data && e.data.type === 'auth_complete') { + resolveOnce(); + } + }; + } catch (e) { + console.warn("BroadcastChannel setup failed:", e); + } + } + + // Method 3: localStorage fallback for very old browsers without BroadcastChannel + function storageHandler(e) { + if (e.key === 'pyret_auth_complete') { + resolveOnce(); + // Clean up the flag + try { localStorage.removeItem('pyret_auth_complete'); } catch (err) {} + } + } + window.addEventListener('storage', storageHandler); + // Clear any stale auth flag before opening popup + try { localStorage.removeItem('pyret_auth_complete'); } catch (e) {} + + // Need to do a login to get a cookie for this user; do it in a popup window.open(path); } else { // The user is logged in, but needs an access token from our server From 238e6174db2bbfb3efcfc670f04bcb9cd934faf2 Mon Sep 17 00:00:00 2001 From: Joe Politz Date: Fri, 30 Jan 2026 15:44:28 -0800 Subject: [PATCH 3/3] [blerner-assisted] cleanup() cleanups - Reorder removing a key with setting a handler to avoid a spurious event - Make sure cleanup clears localStorage and nulls out the channel - Add some useful debugging info we can look for in console.logs I tested this by commenting out cleanup and checking that all 3 modes triggered. Then I added +++ b/src/server.js @@ -45,6 +45,11 @@ function start(config, onServerReady) { app = express(); app.use(bodyParser.urlencoded({ extended: false })) app.use(bodyParser.json()) + app.use(function(req, res, next) { + res.setHeader("Cross-Origin-Opener-Policy", "noopener-allow-popups"); + next(); + }); + And verified that the postMessage failed, but the other two mechanisms succeed --- src/web/close.html | 2 +- src/web/js/google-apis/api-wrapper.js | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/web/close.html b/src/web/close.html index 738b9468..0a9fe8a8 100644 --- a/src/web/close.html +++ b/src/web/close.html @@ -18,7 +18,7 @@ // This is the fallback for environments like GoGuardian that inject COOP headers if (typeof BroadcastChannel !== 'undefined') { try { - var channel = new BroadcastChannel('pyret_auth'); + let channel = new BroadcastChannel('pyret_auth'); channel.postMessage({ type: 'auth_complete' }); channel.close(); } catch (e) { diff --git a/src/web/js/google-apis/api-wrapper.js b/src/web/js/google-apis/api-wrapper.js index 7d4c21ed..7cb72cf1 100644 --- a/src/web/js/google-apis/api-wrapper.js +++ b/src/web/js/google-apis/api-wrapper.js @@ -54,12 +54,19 @@ function reauth(immediate, useFullScopes) { // Track whether we've already resolved to avoid double-resolution var resolved = false; - function resolveOnce() { + function resolveOnce(method) { if (!resolved) { + console.log("INFO: Popup login resolved by: ", method); resolved = true; + // NOTE(joe): A useful thing to do for testing is to comment out this + // cleanup(), and check which of the 3 methods are returning success + // here. cleanup() will stop others from triggering. cleanup(); d.resolve(reauth(true, useFullScopes)); } + else { + console.log("INFO: Popup login resolved again (ignored): ", method); + } } // Cleanup function to remove all listeners @@ -67,8 +74,10 @@ function reauth(immediate, useFullScopes) { function cleanup() { window.removeEventListener('message', messageHandler); window.removeEventListener('storage', storageHandler); + try { localStorage.removeItem('pyret_auth_complete'); } catch (err) {} if (channel) { - try { channel.close(); } catch (e) {} + try { channel.close(); } + finally { channel = null; } } } @@ -76,7 +85,7 @@ function reauth(immediate, useFullScopes) { function messageHandler(e) { // e.domain appears to not be defined in Firefox if ((e.domain || e.origin) === document.location.origin) { - resolveOnce(); + resolveOnce("postMessage"); } } window.addEventListener('message', messageHandler); @@ -88,7 +97,7 @@ function reauth(immediate, useFullScopes) { channel = new BroadcastChannel('pyret_auth'); channel.onmessage = function(e) { if (e.data && e.data.type === 'auth_complete') { - resolveOnce(); + resolveOnce("Broadcast"); } }; } catch (e) { @@ -99,14 +108,14 @@ function reauth(immediate, useFullScopes) { // Method 3: localStorage fallback for very old browsers without BroadcastChannel function storageHandler(e) { if (e.key === 'pyret_auth_complete') { - resolveOnce(); + resolveOnce("localStorage"); // Clean up the flag try { localStorage.removeItem('pyret_auth_complete'); } catch (err) {} } } - window.addEventListener('storage', storageHandler); // Clear any stale auth flag before opening popup try { localStorage.removeItem('pyret_auth_complete'); } catch (e) {} + window.addEventListener('storage', storageHandler); // Need to do a login to get a cookie for this user; do it in a popup window.open(path);