From 1887867500c9bc0d59a7544b41513dead692e45b Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:22:42 +0300
Subject: [PATCH 01/44] JS port: let Display.execute navigate the current page
instead of prompting
Opening a link from the JavaScript port went through a confirmation Sheet
whenever the browser no longer saw a live user gesture -- which is almost
always, since Codename One dispatches events on its own EDT. The Sheet was
also unusable: it put the buttons EAST of a SpanLabel holding the full URL,
and a URL has no spaces to wrap on, so the label's preferred width pushed
the buttons off screen where they could not be tapped. Its OK button then
called CN.execute() recursively, which with no gesture registered re-entered
execute() and could show the Sheet again indefinitely.
Add a javascript.execute.target property with three values:
auto the new default. Opens a new tab while the page still has user
activation and navigates the current page otherwise, so no Sheet
is ever needed.
_blank new tab only, with the Sheet as the fallback (the old behavior).
_self always navigate the page the app runs in.
Every URL-opening path also called window.open() from inside the Web Worker
the app runs in, where window.open does not exist, so even the backside-hook
path could not have worked. Route them through eval_(), which port.js already
forwards to the __cn1_eval_on_main__ host bridge -- no new host handler is
needed and a page built against an older bridge degrades to the Sheet rather
than failing. Drop the now-dead windowOpen native.
Both Sheets now share createConfirmationSheet(), which stacks the message,
the shortened URL and the buttons in a BoxLayout.y() so nothing can push the
buttons out of reach, and the OK button opens the URL directly instead of
recursing.
Document the property and the reason the gesture is lost on Display.execute
and CN.execute, and in the developer guide's "Playing media and opening
links" section, whose opening paragraph still claimed links always prompt.
Co-Authored-By: Claude Opus 5 (1M context)
---
CodenameOne/src/com/codename1/ui/CN.java | 5 +
CodenameOne/src/com/codename1/ui/Display.java | 27 ++
.../impl/html5/HTML5Implementation.java | 311 ++++++++++++++----
.../WorkingWithJavascriptJava009Snippet.java | 99 ++++++
.../Working-With-Javascript.asciidoc | 32 +-
5 files changed, 410 insertions(+), 64 deletions(-)
create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java
diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java
index f02c75e403d..799fbfa0460 100644
--- a/CodenameOne/src/com/codename1/ui/CN.java
+++ b/CodenameOne/src/com/codename1/ui/CN.java
@@ -664,6 +664,11 @@ public static Boolean canExecute(String url) {
/// }
/// ```
///
+ /// On the JavaScript port this can open a new tab, navigate the current page
+ /// or show a confirmation `Sheet`, depending on the
+ /// `javascript.execute.target` property. See [Display#execute(String)] for
+ /// the details.
+ ///
/// #### Parameters
///
/// - `url`: the url to execute
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 83e61ccb8e3..24ea9e50b73 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -4082,6 +4082,33 @@ public Boolean canExecute(String url) {
/// `simulator-hooks.properties` format and the positional `itemN` / `labelN`
/// conventions.
///
+ /// #### JavaScript port
+ ///
+ /// Browsers only let a page open a new window/tab from inside a live user
+ /// gesture, and Codename One dispatches events on its own EDT so by the time
+ /// your listener calls this method the browser no longer considers a gesture
+ /// to be in progress. The JavaScript port therefore resolves the
+ /// `javascript.execute.target` property to decide what to do:
+ ///
+ /// - `auto` (the default) opens a new tab when the page still has user
+ /// activation and otherwise navigates the page the app is running in. No
+ /// confirmation prompt is ever shown, but note that navigating the current
+ /// page unloads the app.
+ /// - `_blank` only ever opens a new tab. When the browser would block it the
+ /// port shows a confirmation `Sheet` whose OK button supplies the missing
+ /// gesture. This was the behavior before the property existed.
+ /// - `_self` always navigates the page the app is running in.
+ ///
+ /// Set it before the call, for example in your `init` method:
+ ///
+ /// ```java
+ /// Display.getInstance().setProperty("javascript.execute.target", "_self");
+ /// ```
+ ///
+ /// The property is ignored on every other platform, and on all targets a
+ /// `javascript:` URL, a `data:` URL or a path to a local file keeps its
+ /// existing meaning.
+ ///
/// #### Parameters
///
/// - `url`: the url to execute
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index cb99941fd43..0c378584586 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6586,10 +6586,6 @@ public boolean isTablet() {
}
- @JSBody(params={"url", "target"}, script="window.open(url, target)")
- private native static void windowOpen(String url, String target);
-
-
// The original implementations of register/fire SaveBlobHandler ran their
// scripts inside the worker, where ``document`` doesn't exist -- the
// generated download element / .click() trick threw the moment the
@@ -6678,7 +6674,233 @@ public boolean paintNativePeersBehind() {
@JSBody(params={"js"}, script="eval(js)")
private native static void eval_(String js);
-
+
+ /**
+ * Value of the {@code javascript.execute.target} property: open in a new
+ * window/tab and silently fall back to navigating the current page when the
+ * browser blocks the popup. This is the default -- it is the only mode that
+ * never needs a confirmation Sheet.
+ */
+ private static final String EXECUTE_TARGET_AUTO = "auto";
+
+ /**
+ * Value of the {@code javascript.execute.target} property: only ever open a
+ * new window/tab. When the browser blocks it (no live user gesture) a
+ * confirmation Sheet is shown so the user's tap on it supplies the gesture.
+ */
+ private static final String EXECUTE_TARGET_BLANK = "_blank";
+
+ /**
+ * Value of the {@code javascript.execute.target} property: always navigate
+ * the page the app is running in. Never blocked, never shows a Sheet, but
+ * it unloads the app.
+ */
+ private static final String EXECUTE_TARGET_SELF = "_self";
+
+ /**
+ * Resolves the {@code javascript.execute.target} property, accepting the
+ * target names with or without the leading underscore.
+ */
+ private String executeTarget() {
+ String target = Display.getInstance().getProperty("javascript.execute.target", EXECUTE_TARGET_AUTO);
+ if (EXECUTE_TARGET_SELF.equals(target) || "self".equals(target)) {
+ return EXECUTE_TARGET_SELF;
+ }
+ if (EXECUTE_TARGET_BLANK.equals(target) || "blank".equals(target) || "new".equals(target)) {
+ return EXECUTE_TARGET_BLANK;
+ }
+ return EXECUTE_TARGET_AUTO;
+ }
+
+ /**
+ * Quotes a string as a JavaScript string literal so it can be embedded in a
+ * script handed to {@link #eval_(String)}.
+ */
+ private static String toJavaScriptStringLiteral(String value) {
+ StringBuilder sb = new StringBuilder(value.length() + 8);
+ sb.append('"');
+ int len = value.length();
+ for (int i = 0; i < len; i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ case '<':
+ // Avoid terminating an enclosing script element early.
+ sb.append("\\u003c");
+ break;
+ default:
+ if (c < ' ' || c > 126) {
+ String hex = Integer.toHexString(c);
+ sb.append("\\u");
+ for (int j = hex.length(); j < 4; j++) {
+ sb.append('0');
+ }
+ sb.append(hex);
+ } else {
+ sb.append(c);
+ }
+ break;
+ }
+ }
+ sb.append('"');
+ return sb.toString();
+ }
+
+ /**
+ * Hands a URL to the page's main thread for opening. {@code window} and
+ * {@code window.open} do not exist inside the Web Worker the app runs in, so
+ * this routes through the eval-on-main host bridge (see the
+ * {@code eval_} binding in port.js) where the page's real window lives.
+ *
+ * @param url the URL to open
+ * @param sameWindow when true navigate the current page, when false open a
+ * new window/tab
+ * @param fallBackToSameWindow when opening a new window, navigate the
+ * current page instead if the browser blocked the popup. The check runs
+ * inside the page-side script because the popup verdict is only knowable
+ * there.
+ * @return true if the script reached the page, false if the main-thread
+ * bridge is unavailable and the caller should degrade
+ */
+ private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fallBackToSameWindow) {
+ String literal = toJavaScriptStringLiteral(url);
+ String script;
+ if (sameWindow) {
+ script = "window.location.href = " + literal + ";";
+ } else if (fallBackToSameWindow) {
+ // Only attempt the popup while the page still has transient user
+ // activation -- otherwise the browser blocks it and shows its
+ // "popup blocked" indicator before we navigate anyway. Browsers
+ // without navigator.userActivation just try and let !cn1w decide.
+ script = "var cn1a = window.navigator && window.navigator.userActivation;"
+ + "var cn1w = (!cn1a || cn1a.isActive) ? window.open(" + literal + ", '_blank') : null;"
+ + "if (!cn1w) { window.location.href = " + literal + "; }";
+ } else {
+ script = "window.open(" + literal + ", '_blank');";
+ }
+ try {
+ eval_(script);
+ return true;
+ } catch (Throwable t) {
+ _log("Failed to open URL on the main thread: " + t);
+ return false;
+ }
+ }
+
+ /**
+ * A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one
+ * in the confirmation Sheet blew the content pane's preferred width past the
+ * screen and pushed the buttons out of reach. Shorten it for display; the
+ * full URL is never needed to answer "open this link?".
+ */
+ static String shortenUrlForDisplay(String url) {
+ if (url == null) {
+ return "";
+ }
+ String shortened = url;
+ int schemeEnd = shortened.indexOf("://");
+ if (schemeEnd >= 0) {
+ shortened = shortened.substring(schemeEnd + 3);
+ }
+ if (shortened.length() > 40) {
+ int slash = shortened.indexOf('/');
+ if (slash > 0 && slash <= 40) {
+ shortened = shortened.substring(0, slash) + "/...";
+ } else {
+ shortened = shortened.substring(0, 37) + "...";
+ }
+ }
+ return shortened;
+ }
+
+ /**
+ * Builds the "browser blocked this, please confirm" Sheet used by
+ * {@link #execute(String)}. The message, the (shortened) URL and the buttons
+ * each get their own row: the old single-row BorderLayout put the buttons
+ * east of a label whose preferred width was the whole URL, which pushed them
+ * off screen where they could not be tapped.
+ */
+ private Sheet createConfirmationSheet(String title, String message, String detail, final Runnable onConfirm) {
+ final Sheet sheet = new Sheet(null, title);
+ SpanLabel messageLabel = new SpanLabel(message);
+ Button ok = new Button("OK");
+ Button cancel = new Button("Cancel");
+ ok.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ sheet.back();
+ onConfirm.run();
+ }
+ });
+ cancel.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ sheet.back();
+ }
+ });
+ sheet.getContentPane().setLayout(BoxLayout.y());
+ sheet.getContentPane().add(messageLabel);
+ if (detail != null && detail.length() > 0) {
+ // A plain Label truncates with an ellipsis when it does not fit,
+ // unlike SpanLabel which would just overflow on an unbreakable URL.
+ Label detailLabel = new Label(detail);
+ detailLabel.setEndsWith3Points(true);
+ sheet.getContentPane().add(detailLabel);
+ }
+ sheet.getContentPane().add(FlowLayout.encloseCenter(cancel, ok));
+ return sheet;
+ }
+
+ /**
+ * Opens a URL, or asks the user to, honoring the
+ * {@code javascript.execute.target} property. See {@link #execute(String)}.
+ */
+ private void openExternalUrl(final String url) {
+ String target = executeTarget();
+ if (EXECUTE_TARGET_SELF.equals(target)) {
+ openUrlOnMainThread(url, true, false);
+ return;
+ }
+ if (EXECUTE_TARGET_AUTO.equals(target)
+ && openUrlOnMainThread(url, false, true)) {
+ return;
+ }
+ // _blank, or auto on a host bundle without the eval-on-main handler:
+ // a popup only survives inside a live user gesture, so ride the backside
+ // hook when one is pending and otherwise ask the user, whose tap on the
+ // Sheet becomes the gesture.
+ if (isBacksideHookAvailable()) {
+ addBacksideHook(new JSRunnable() {
+ @Override
+ public void run() {
+ openUrlOnMainThread(url, false, false);
+ }
+ });
+ return;
+ }
+ createConfirmationSheet("Open Link", "Open this link in a new window?",
+ shortenUrlForDisplay(url), new Runnable() {
+ @Override
+ public void run() {
+ openUrlOnMainThread(url, false, false);
+ }
+ }).show();
+ }
+
@Override
public void execute(String url) {
if (url.startsWith("javascript:")) {
@@ -6730,80 +6952,43 @@ public void execute(String url) {
nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
//icon = "save-file";
} else {
- //popover.setContents("Open URL in New Window");
- if (isBacksideHookAvailable()) {
- addBacksideHook(new JSRunnable() {
- public void run() {
- window.open(furl, "New Window");
- }
- });
-
- } else {
- final Sheet sheet = new Sheet(null, "Open Link");
- SpanLabel l = new SpanLabel("Open "+url+" in a new window?");
- Button ok = new Button("OK");
- Button cancel = new Button("Cancel");
- ok.addActionListener(new ActionListener() {
- public void actionPerformed(ActionEvent evt) {
- sheet.back();
- CN.execute(furl);
- }
- });
- cancel.addActionListener(new ActionListener() {
- public void actionPerformed(ActionEvent evt) {
- sheet.back();
- }
- });
- sheet.getContentPane().setLayout(BoxLayout.y());
- sheet.getContentPane().add(BorderLayout.centerEastWest(l, FlowLayout.encloseIn(ok, cancel), null));
- sheet.show();
-
- }
+ openExternalUrl(furl);
return;
-
}
+ final Runnable startDownload = new Runnable() {
+ @Override
+ public void run() {
+ if (fuseBlobHandler) {
+ fireSaveBlobHandler();
+ } else {
+ _log("Opening URL in new window");
+ openUrlOnMainThread(furl, false, false);
+ }
+ }
+ };
if (isBacksideHookAvailable()) {
addBacksideHook(new JSRunnable() {
+ @Override
public void run() {
- if (fuseBlobHandler) {
- fireSaveBlobHandler();
- } else {
- _log("Opening URL in new window");
- window.open(furl, "New Window");
- }
+ startDownload.run();
}
});
} else {
- final Sheet sheet = new Sheet(null, "Download file");
String dlName = fileName == null ? "file" : fileName;
- SpanLabel l = new SpanLabel("Download "+dlName+" now?");
- Button ok = new Button("OK");
- Button cancel = new Button("Cancel");
- ok.addActionListener(new ActionListener() {
- public void actionPerformed(ActionEvent evt) {
- sheet.back();
+ createConfirmationSheet("Download file", "Download " + dlName + " now?", null, new Runnable() {
+ @Override
+ public void run() {
addBacksideHook(new JSRunnable() {
+ @Override
public void run() {
- if (fuseBlobHandler) {
- fireSaveBlobHandler();
- } else {
- window.open(furl, "New Window");
- }
+ startDownload.run();
}
});
}
- });
- cancel.addActionListener(new ActionListener() {
- public void actionPerformed(ActionEvent evt) {
- sheet.back();
- }
- });
- sheet.getContentPane().setLayout(BoxLayout.y());
- sheet.getContentPane().add(BorderLayout.centerEastWest(l, FlowLayout.encloseIn(ok, cancel), null));
- sheet.show();
+ }).show();
}
-
+
}
diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java
new file mode 100644
index 00000000000..9c7881760ab
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Codename One in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.snippets.generated;
+
+import com.codename1.gpu.*;
+import com.codename1.ui.*;
+import com.codename1.ui.animations.*;
+import com.codename1.ui.events.*;
+import com.codename1.ui.geom.*;
+import com.codename1.ui.layouts.*;
+import com.codename1.ui.list.*;
+import com.codename1.ui.plaf.*;
+import com.codename1.ui.util.*;
+import com.codename1.components.*;
+import com.codename1.charts.models.*;
+import com.codename1.charts.renderers.*;
+import com.codename1.charts.views.*;
+import com.codename1.capture.*;
+import com.codename1.io.*;
+import com.codename1.l10n.*;
+import com.codename1.location.*;
+import com.codename1.maps.*;
+import com.codename1.media.*;
+import com.codename1.messaging.*;
+import com.codename1.payment.*;
+import com.codename1.processing.*;
+import com.codename1.properties.*;
+import com.codename1.push.*;
+import com.codename1.security.*;
+import com.codename1.social.*;
+import com.codename1.ui.spinner.*;
+import java.io.*;
+import java.util.*;
+
+
+class WorkingWithJavascriptJava009Snippet {
+
+ Object context;
+ Object url;
+ Object value;
+ Object body;
+ Object event;
+ String apiKey = "test-key";
+ String myHttpsURL = "https://example.com";
+ java.util.List validKeysList = new java.util.ArrayList<>();
+ Image myImage;
+ Graphics graphics;
+ Graphics g;
+ GraphicsDevice device;
+ Form form;
+ Form hi;
+ Container cnt;
+ Container myForm;
+ Component component;
+ Button button;
+ MultiButton myMultiButton;
+ Label label;
+ BrowserComponent browserComponent;
+ Resources theme;
+ void snippet() throws Exception {
+ // tag::working-with-javascript-java-009[]
+ // The default. Opens a new tab while the browser still considers a
+ // user gesture to be in progress, and navigates the page the app runs
+ // in when it doesn't. Never shows a confirmation sheet.
+ CN.setProperty("javascript.execute.target", "auto");
+
+ // Always navigate the page the app runs in. Never blocked and never
+ // prompts, but it unloads the app.
+ CN.setProperty("javascript.execute.target", "_self");
+
+ // Only ever open a new tab. When the browser blocks that, the port
+ // shows a confirmation sheet whose OK button supplies the gesture the
+ // browser wanted. This was the behavior before the property existed.
+ CN.setProperty("javascript.execute.target", "_blank");
+
+ CN.execute("https://www.codenameone.com/");
+ // end::working-with-javascript-java-009[]
+ }
+}
diff --git a/docs/developer-guide/Working-With-Javascript.asciidoc b/docs/developer-guide/Working-With-Javascript.asciidoc
index 3927d540b8c..14ef1e53cf6 100644
--- a/docs/developer-guide/Working-With-Javascript.asciidoc
+++ b/docs/developer-guide/Working-With-Javascript.asciidoc
@@ -609,7 +609,7 @@ NOTE: The Chrome App Launcher lists apps installed both via the Chrome Web Store
People don't like it when the browser automatically starts playing sounds, or opening links without their permission. For this reason, modern browsers restrict your ability to programmatically do these things, unless they're in response to a user action, like a mouse click.
-If your app needs to play media (for example, `Media.play()`), or open a link (for example, `Display.execute("...")`) without the user actually interacting physically (for example, key press or pointer press), then it will display a popup dialog confirming that the user actually wants to perform this action.
+If your app needs to play media (for example, `Media.play()`) without the user actually interacting physically (for example, key press or pointer press), then it will display a popup dialog confirming that the user actually wants to perform this action. Opening a link (for example, `Display.execute("...")`) used to do the same, but now defaults to navigating the current page instead of prompting -- see <> below.
Sometimes this dialog may affect the utility of the app. For example, suppose you want to play a video in response to a voice command. Having to press an "OK" button after the command, may be annoying. For such cases, you can use the `platformHint.javascript.backsideHooksInterval` property to *poll* for media play requests on an authorized event.
@@ -622,6 +622,36 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g
WARNING: Don't abuse this feature. You should enable this polling when necessary. For example, If your app enables the user to listen for voice commands, enable polling for the period of time that it's listening. When the user wants to stop listening, you should also stop the polling by setting the interval to "0."
+[id="opening-links", reftext="Opening links"]
+==== Choosing where a link opens
+
+Opening a link deserves its own note, because the restriction above bites almost every app that calls `Display.execute()`. Codename One dispatches events on its own EDT, so by the time your action listener runs, the browser no longer sees the tap as an ongoing user gesture and refuses to open a new window. Navigating the page the app already runs in has no such restriction: it always works, at the cost of unloading the app.
+
+The port picks between those two with the `javascript.execute.target` property:
+
+[cols="1,4"]
+|===
+|Value |Behavior
+
+|`auto`
+|The default. Opens a new tab while the page still has user activation, and navigates the current page when it doesn't. No confirmation sheet is ever shown.
+
+|`_blank`
+|Only ever opens a new tab. When the browser blocks that, the port shows a confirmation sheet whose OK button supplies the gesture the browser wanted. This was the behavior before the property existed.
+
+|`_self`
+|Always navigates the page the app runs in.
+|===
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/WorkingWithJavascriptJava009Snippet.java[tag=working-with-javascript-java-009,indent=0]
+----
+
+NOTE: Under `auto` and `_self` the app is unloaded whenever the current page navigates away, so persist anything you need before you call `execute()`. Pick `_blank` when keeping the app alive matters more than avoiding the sheet.
+
+The property only affects external links. A `javascript:` URL still evaluates on the page, and a `data:` URL or a path to a local file still becomes a download.
+
[id="browsercomponent-lifecycle", reftext="BrowserComponent Lifecycle"]
=== BrowserComponent lifecycle
From 165d4487b1100016f17cb0e2257ea20427d9ba20 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:27:34 +0300
Subject: [PATCH 02/44] JS port: queue the confirmed _blank open on the
backside hook
The Sheet's OK callback runs on the Codename One EDT, by which point the
browser no longer counts the tap as an ongoing gesture -- so opening from
there could be discarded by a popup blocker, which is exactly the situation
the Sheet exists to recover from. The OK tap does install a backside hook
(pointer handling calls installBacksideHooksInUserInteraction), so queue the
open on it and let the drain perform it inside the gesture, matching the
download confirmation callback.
This also restores what the removed CN.execute() recursion was doing, without
its risk of re-entering execute() and re-showing the Sheet.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/impl/html5/HTML5Implementation.java | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 0c378584586..7e1d260447d 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6896,7 +6896,20 @@ public void run() {
shortenUrlForDisplay(url), new Runnable() {
@Override
public void run() {
- openUrlOnMainThread(url, false, false);
+ // This callback runs on the EDT, by which point the browser no
+ // longer counts the OK tap as an ongoing gesture -- opening
+ // from here would be discarded by a popup blocker, which is the
+ // very thing the Sheet exists to recover from. That tap did
+ // install a backside hook though (pointer handling calls
+ // installBacksideHooksInUserInteraction), so queue the open on
+ // it and let the drain perform it inside the gesture. Same
+ // shape as the download confirmation below.
+ addBacksideHook(new JSRunnable() {
+ @Override
+ public void run() {
+ openUrlOnMainThread(url, false, false);
+ }
+ });
}
}).show();
}
From 69d6f0b5cf8a181796f6c648206a75bef222ca3f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:33:41 +0300
Subject: [PATCH 03/44] JS port: degrade properly when the page-side open
cannot run
Three fixes from review:
Assert page context in the injected script. When the host bundle predates
the __cn1_eval_on_main__ handler, port.js degrades to evaluating the script
inside the worker. Opening fails loudly there, but a same-window navigation
would not: assigning to the worker's read-only location is a silent no-op in
non-strict eval code, so execute() would look like it succeeded and do
nothing. Every variant now starts with a document check so the worker case
throws and the caller can react.
Degrade _self on that failure. The _self branch ignored the return value and
returned, leaving execute() with no effect. It now falls through to the same
backside-hook / confirmation-Sheet path auto already used.
Keep failed local downloads off the target policy. A local path whose blob
setup failed -- exists() false, or openFileAsBlob() throwing, which the
downloadBytesAsFile comment records as easy to hit on this port -- was being
handed to openExternalUrl(). Under the auto default that pointed the page at
a storage path the server does not serve, unloading the app instead of
downloading. Only genuinely navigable schemes now take that path, via a new
isExternalUrl(); everything else keeps the previous best-effort new window.
That predicate also fixes a duplicated "http:" test that was meant to be
"https:".
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 55 +++++++++++++++----
1 file changed, 44 insertions(+), 11 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 7e1d260447d..2559bf47676 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6778,19 +6778,29 @@ private static String toJavaScriptStringLiteral(String value) {
*/
private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fallBackToSameWindow) {
String literal = toJavaScriptStringLiteral(url);
+ // When the host bundle predates the __cn1_eval_on_main__ handler,
+ // port.js degrades to evaluating the script inside the worker. Opening
+ // fails loudly there (no window.open), but a same-window navigation
+ // would NOT: assigning to the worker's read-only location is a silent
+ // no-op in non-strict eval code, so execute() would appear to succeed
+ // and do nothing. Assert we are on the page so every variant fails
+ // detectably and the caller can degrade.
+ String guard = "if (typeof document === 'undefined') {"
+ + " throw new Error('cn1: not running on the page'); }";
String script;
if (sameWindow) {
- script = "window.location.href = " + literal + ";";
+ script = guard + "window.location.href = " + literal + ";";
} else if (fallBackToSameWindow) {
// Only attempt the popup while the page still has transient user
// activation -- otherwise the browser blocks it and shows its
// "popup blocked" indicator before we navigate anyway. Browsers
// without navigator.userActivation just try and let !cn1w decide.
- script = "var cn1a = window.navigator && window.navigator.userActivation;"
+ script = guard
+ + "var cn1a = window.navigator && window.navigator.userActivation;"
+ "var cn1w = (!cn1a || cn1a.isActive) ? window.open(" + literal + ", '_blank') : null;"
+ "if (!cn1w) { window.location.href = " + literal + "; }";
} else {
- script = "window.open(" + literal + ", '_blank');";
+ script = guard + "window.open(" + literal + ", '_blank');";
}
try {
eval_(script);
@@ -6865,21 +6875,36 @@ public void actionPerformed(ActionEvent evt) {
return sheet;
}
+ /**
+ * True when the URL names something the browser itself can navigate to, as
+ * opposed to a path into local storage that {@link #execute(String)} is
+ * expected to turn into a download. Only these get the
+ * {@code javascript.execute.target} treatment -- pointing the current page
+ * at a storage path would unload the app and land on nothing.
+ */
+ private static boolean isExternalUrl(String url) {
+ return url.startsWith("http:") || url.startsWith("https:")
+ || url.startsWith("mailto:") || url.startsWith("tel:")
+ || url.startsWith("sms:");
+ }
+
/**
* Opens a URL, or asks the user to, honoring the
* {@code javascript.execute.target} property. See {@link #execute(String)}.
*/
private void openExternalUrl(final String url) {
String target = executeTarget();
- if (EXECUTE_TARGET_SELF.equals(target)) {
- openUrlOnMainThread(url, true, false);
+ if (EXECUTE_TARGET_SELF.equals(target)
+ && openUrlOnMainThread(url, true, false)) {
return;
}
if (EXECUTE_TARGET_AUTO.equals(target)
&& openUrlOnMainThread(url, false, true)) {
return;
}
- // _blank, or auto on a host bundle without the eval-on-main handler:
+ // _blank, or _self / auto whose page-side script never ran because the
+ // host bundle predates the eval-on-main handler -- degrade rather than
+ // leave execute() with no effect at all:
// a popup only survives inside a live user gesture, so ride the backside
// hook when one is pending and otherwise ask the user, whose tap on the
// Sheet becomes the gesture.
@@ -6925,10 +6950,7 @@ public void execute(String url) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- if (!url.startsWith("http:") &&
- !url.startsWith("http:") &&
- !url.startsWith("mailto:") &&
- !url.startsWith("data:")) {
+ if (!isExternalUrl(url) && !url.startsWith("data:")) {
if (exists(url)) {
try {
Blob blob = openFileAsBlob(url);
@@ -6964,9 +6986,20 @@ public void execute(String url) {
nativeButton.setText(buttonText);
nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
//icon = "save-file";
- } else {
+ } else if (isExternalUrl(url)) {
openExternalUrl(furl);
return;
+ } else {
+ // A local/storage path we could not turn into a Blob: exists() said
+ // no, or openFileAsBlob() threw (see downloadBytesAsFile above on
+ // how readily the storage round trip fails on this port). Keep it
+ // off the external-link target policy -- under auto or _self that
+ // would point the page at a path the server does not serve and
+ // unload the app instead of downloading anything. Best effort in a
+ // new window, which is what this port did before.
+ _log("execute(): no downloadable content for " + furl);
+ openUrlOnMainThread(furl, false, false);
+ return;
}
final Runnable startDownload = new Runnable() {
@Override
From 1e168e2939f34e93b1a0e156f90febf0f6563ab5 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:40:38 +0300
Subject: [PATCH 04/44] JS port: classify any URI scheme as external, not a
fixed list
The previous allowlist (http/https/mailto/tel/sms) demoted custom deep links
such as the imdb:///find example in Display.execute's own javadoc, along with
uppercase schemes and protocol-relative URLs, to a bare window.open() that
skipped the gesture-aware flow and ignored an explicit _self target. Before
this branch those URLs went through the backside-hook / confirmation path.
Classify by structure instead: anything carrying an RFC 3986 scheme is
external, whatever its case, as is a protocol-relative //host/path. file: is
the exception, since like the bare paths that carry no scheme at all it names
local content -- which is what keeps failed local downloads off the target
policy. A single character before the colon is read as a Windows drive letter
rather than a scheme, and a path that merely contains a colon still fails the
scheme grammar.
data: is now covered by the predicate, so drop the redundant second test at
the top of execute(); its own branch still runs first and downloads it.
Co-Authored-By: Claude Opus 5 (1M context)
---
CodenameOne/src/com/codename1/ui/Display.java | 8 ++--
.../impl/html5/HTML5Implementation.java | 38 ++++++++++++++++---
.../Working-With-Javascript.asciidoc | 2 +-
3 files changed, 39 insertions(+), 9 deletions(-)
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 24ea9e50b73..91c37725119 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -4105,9 +4105,11 @@ public Boolean canExecute(String url) {
/// Display.getInstance().setProperty("javascript.execute.target", "_self");
/// ```
///
- /// The property is ignored on every other platform, and on all targets a
- /// `javascript:` URL, a `data:` URL or a path to a local file keeps its
- /// existing meaning.
+ /// The property applies to any URL carrying a URI scheme the browser can
+ /// hand off, custom deep links like the `imdb:///find` example above
+ /// included. It is ignored on every other platform, and on all targets a
+ /// `javascript:` URL, a `data:` URL, a `file:` URL or a path into local
+ /// storage keeps its existing meaning.
///
/// #### Parameters
///
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 2559bf47676..bd1e1ecdd06 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6876,16 +6876,41 @@ public void actionPerformed(ActionEvent evt) {
}
/**
- * True when the URL names something the browser itself can navigate to, as
+ * True when the URL names something the browser itself can hand off, as
* opposed to a path into local storage that {@link #execute(String)} is
* expected to turn into a download. Only these get the
* {@code javascript.execute.target} treatment -- pointing the current page
* at a storage path would unload the app and land on nothing.
+ *
+ *
Anything carrying a URI scheme qualifies, whatever its case. That
+ * deliberately includes custom deep links such as the {@code imdb:///find}
+ * example in {@code Display.execute}'s javadoc, which the browser passes to
+ * a registered handler, and it includes protocol-relative {@code //host/path}
+ * URLs. {@code file:} is the exception: like the bare paths that carry no
+ * scheme at all, it names local content.
*/
private static boolean isExternalUrl(String url) {
- return url.startsWith("http:") || url.startsWith("https:")
- || url.startsWith("mailto:") || url.startsWith("tel:")
- || url.startsWith("sms:");
+ if (url.startsWith("//")) {
+ // Protocol relative -- the page's own scheme applies.
+ return true;
+ }
+ int colon = url.indexOf(':');
+ // A single character before the colon is a Windows drive letter rather
+ // than a URI scheme; every scheme worth handing off is longer.
+ if (colon < 2) {
+ return false;
+ }
+ // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), per RFC 3986. A
+ // path that merely happens to contain a colon fails this.
+ for (int i = 0; i < colon; i++) {
+ char c = url.charAt(i);
+ boolean alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ boolean extra = i > 0 && ((c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.');
+ if (!alpha && !extra) {
+ return false;
+ }
+ }
+ return !"file".equals(url.substring(0, colon).toLowerCase());
}
/**
@@ -6950,7 +6975,10 @@ public void execute(String url) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- if (!isExternalUrl(url) && !url.startsWith("data:")) {
+ // Only a local path can name storage content to wrap in a Blob. A
+ // data: URL carries its own payload and is handled further down;
+ // isExternalUrl already excludes it, since it has a scheme.
+ if (!isExternalUrl(url)) {
if (exists(url)) {
try {
Blob blob = openFileAsBlob(url);
diff --git a/docs/developer-guide/Working-With-Javascript.asciidoc b/docs/developer-guide/Working-With-Javascript.asciidoc
index 14ef1e53cf6..e347b3389ee 100644
--- a/docs/developer-guide/Working-With-Javascript.asciidoc
+++ b/docs/developer-guide/Working-With-Javascript.asciidoc
@@ -650,7 +650,7 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g
NOTE: Under `auto` and `_self` the app is unloaded whenever the current page navigates away, so persist anything you need before you call `execute()`. Pick `_blank` when keeping the app alive matters more than avoiding the sheet.
-The property only affects external links. A `javascript:` URL still evaluates on the page, and a `data:` URL or a path to a local file still becomes a download.
+The property applies to any URL the browser can hand off, which means anything carrying a URI scheme. Custom deep links such as `imdb:///find?q=godfather` count, and so do protocol-relative `//host/path` URLs. The exceptions keep their existing meaning: a `javascript:` URL still evaluates on the page, and a `data:` URL, a `file:` URL or a path into local storage still becomes a download.
[id="browsercomponent-lifecycle", reftext="BrowserComponent Lifecycle"]
=== BrowserComponent lifecycle
From 84c0423c78172d26c15c995e4f3252bef072844d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:43:18 +0300
Subject: [PATCH 05/44] JS port: name the URL shortening thresholds, log a
failed confirmed open
Review nits. Replace the 40 / 37 literals in shortenUrlForDisplay with
MAX_DISPLAY_URL_LENGTH and DISPLAY_URL_ELLIPSIS, deriving the truncation
point from the marker's length rather than restating it. Verified
behavior-identical across the empty, short, long-path and long-host cases.
Also report the one dead end the Sheet cannot recover from: when the user
confirms and openUrlOnMainThread still fails, the host bundle has no
eval-on-main handler, so the worker has no page-side channel at all and
re-showing the Sheet would loop. The result was discarded silently; log it
instead so it is diagnosable.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 30 +++++++++++++++----
1 file changed, 25 insertions(+), 5 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index bd1e1ecdd06..a816d7b678c 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6811,6 +6811,15 @@ private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fall
}
}
+ /**
+ * Longest URL the confirmation Sheet shows before it is ellipsized. Chosen
+ * to stay inside a narrow phone viewport at the default font.
+ */
+ private static final int MAX_DISPLAY_URL_LENGTH = 40;
+
+ /** Marker appended to a URL shortened for the confirmation Sheet. */
+ private static final String DISPLAY_URL_ELLIPSIS = "...";
+
/**
* A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one
* in the confirmation Sheet blew the content pane's preferred width past the
@@ -6826,12 +6835,14 @@ static String shortenUrlForDisplay(String url) {
if (schemeEnd >= 0) {
shortened = shortened.substring(schemeEnd + 3);
}
- if (shortened.length() > 40) {
+ if (shortened.length() > MAX_DISPLAY_URL_LENGTH) {
int slash = shortened.indexOf('/');
- if (slash > 0 && slash <= 40) {
- shortened = shortened.substring(0, slash) + "/...";
+ if (slash > 0 && slash <= MAX_DISPLAY_URL_LENGTH) {
+ // Host fits: keep it whole and stand in for the path.
+ shortened = shortened.substring(0, slash) + "/" + DISPLAY_URL_ELLIPSIS;
} else {
- shortened = shortened.substring(0, 37) + "...";
+ shortened = shortened.substring(0, MAX_DISPLAY_URL_LENGTH - DISPLAY_URL_ELLIPSIS.length())
+ + DISPLAY_URL_ELLIPSIS;
}
}
return shortened;
@@ -6957,7 +6968,16 @@ public void run() {
addBacksideHook(new JSRunnable() {
@Override
public void run() {
- openUrlOnMainThread(url, false, false);
+ if (!openUrlOnMainThread(url, false, false)) {
+ // The user confirmed and we still could not open.
+ // Only reachable on a host bundle with no
+ // eval-on-main handler, where the worker has no
+ // page-side channel at all -- there is nothing
+ // further to fall back to, and re-showing the Sheet
+ // would just loop. Say so plainly in the log.
+ _log("execute(): confirmed open failed, no page-side"
+ + " channel available for " + url);
+ }
}
});
}
From e7f9610f85a247328a91473087dbf163b9980b38 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:49:26 +0300
Subject: [PATCH 06/44] JS port: don't treat the polling interval as popup
authorization
isBacksideHookAvailable() is true whenever EITHER a gesture-backed drain is
pending OR platformHint.javascript.backsideHooksInterval is running. That
disjunction is right for media, since browsers relax autoplay once the page
has engagement, but not for a popup: the interval drains from
Window.setInterval, which grants no transient activation, so _blank queued
the open and returned without a Sheet while the browser could block it
silently -- the user gets nothing, which is worse than the Sheet _blank
promises.
Gate the popup path on a new isGestureBackedHookAvailable(), which reads only
the semaphore raised by installBacksideHooksInUserInteraction() off a real
pointer or key event. Interval-only availability now falls through to the
confirmation Sheet.
The download call sites keep using isBacksideHookAvailable() on purpose:
their authorization story is different and the blob flow there is what the
Initializr Generate path depends on.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 27 ++++++++++++++++---
1 file changed, 24 insertions(+), 3 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index a816d7b678c..59d4be5dcf6 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6886,6 +6886,24 @@ public void actionPerformed(ActionEvent evt) {
return sheet;
}
+ /**
+ * True when a backside-hook drain is pending because of an actual user
+ * gesture, rather than merely because the polling interval enabled by
+ * {@code platformHint.javascript.backsideHooksInterval} is running.
+ *
+ *
{@link #isBacksideHookAvailable()} accepts either, which is right for
+ * media -- browsers relax autoplay once the page has engagement -- but not
+ * for a popup. The interval drains from {@code Window.setInterval}, which
+ * grants no transient activation, so a {@code window.open()} queued on it
+ * can be blocked with nothing shown to the user. The semaphore, in
+ * contrast, is only raised by
+ * {@code installBacksideHooksInUserInteraction()} off a real pointer or key
+ * event.
+ */
+ private boolean isGestureBackedHookAvailable() {
+ return backsideHooksSemaphore > 0;
+ }
+
/**
* True when the URL names something the browser itself can hand off, as
* opposed to a path into local storage that {@link #execute(String)} is
@@ -6942,9 +6960,12 @@ && openUrlOnMainThread(url, false, true)) {
// host bundle predates the eval-on-main handler -- degrade rather than
// leave execute() with no effect at all:
// a popup only survives inside a live user gesture, so ride the backside
- // hook when one is pending and otherwise ask the user, whose tap on the
- // Sheet becomes the gesture.
- if (isBacksideHookAvailable()) {
+ // hook when a gesture-backed one is pending and otherwise ask the user,
+ // whose tap on the Sheet becomes the gesture. Note this deliberately
+ // ignores an interval-only hook: draining from a timer would let the
+ // browser block the popup with nothing shown, which is worse than the
+ // Sheet that _blank promises.
+ if (isGestureBackedHookAvailable()) {
addBacksideHook(new JSRunnable() {
@Override
public void run() {
From 6d04d981ba1031df888b049f110677e0e2919f19 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:54:24 +0300
Subject: [PATCH 07/44] JS port: accept single character URI schemes
RFC 3986 allows a scheme of one ALPHA, so x:payload is a legitimate deep
link, but isExternalUrl() demanded two characters and classified it as a
storage path -- the lookup then failed and it took the no-content popup
branch, ignoring an explicit _self or auto target.
The two character floor was there to keep a Windows drive letter from
reading as a scheme. That cannot arise on this port:
getFileSystemSeparator() is '/', so no storage path it hands us is drive
qualified. Drop it.
Checked across 27 inputs: x:payload, s:1 and a1+b-c.d:z classify external
alongside the existing cases, while :noscheme, 1abc:x, /dir/a:b.txt and the
bare storage paths still classify local.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/html5/HTML5Implementation.java | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 59d4be5dcf6..ac54555ea2f 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6924,9 +6924,12 @@ private static boolean isExternalUrl(String url) {
return true;
}
int colon = url.indexOf(':');
- // A single character before the colon is a Windows drive letter rather
- // than a URI scheme; every scheme worth handing off is longer.
- if (colon < 2) {
+ // RFC 3986 allows a single ALPHA scheme, so x:payload is a legitimate
+ // deep link. The usual reason to demand two characters is to avoid
+ // reading a Windows drive letter as a scheme, which cannot arise here:
+ // getFileSystemSeparator() is '/' on this port, so no storage path it
+ // hands us is drive qualified.
+ if (colon < 1) {
return false;
}
// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), per RFC 3986. A
From 2f64061ad584bb272d4c479e80229479b8b40f12 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:58:05 +0300
Subject: [PATCH 08/44] JS port: document the data: branch ordering dependency
in execute()
isExternalUrl() is true for data: as well, since it does carry a scheme, so
execute() depends on the data: branch staying ahead of the external one --
otherwise a data: URL would navigate instead of downloading. The dependency
was correct but unstated, which makes it easy to break in a reorder.
No behavior change.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../java/com/codename1/impl/html5/HTML5Implementation.java | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index ac54555ea2f..9087b1dceb7 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7059,6 +7059,10 @@ public void execute(String url) {
nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
//icon = "save-file";
} else if (isExternalUrl(url)) {
+ // Reached only by URLs the data: branch above did not claim.
+ // isExternalUrl() is true for data: as well -- it does carry a
+ // scheme -- so that branch MUST stay ahead of this one or data:
+ // URLs would navigate instead of downloading.
openExternalUrl(furl);
return;
} else {
From 4689ddb5b561cc58f2af5d3a58d702e5b9a352f7 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 05:16:16 +0300
Subject: [PATCH 09/44] JS port: locale-safe scheme compare, documented targets
only, drop furl
Three review nits.
Use "file".equalsIgnoreCase(scheme) rather than lowercasing through the
default locale, where a Turkish locale folds "FILE" to "fIle" and would
classify a file: URL as external.
Accept only the three documented javascript.execute.target values. The
undocumented self / blank / new aliases were surprising configuration
surface; an unrecognized value is now logged rather than silently treated as
auto, so a typo is diagnosable. Nothing has shipped with the aliases, so
there is no compatibility concern.
Drop the furl alias. url is never reassigned in execute(), so it is already
effectively final and capturable by the anonymous classes directly. The alias
only created the url-versus-furl split the review flagged.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 28 ++++++++++++-------
1 file changed, 18 insertions(+), 10 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 9087b1dceb7..40db247686a 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6698,17 +6698,23 @@ public boolean paintNativePeersBehind() {
private static final String EXECUTE_TARGET_SELF = "_self";
/**
- * Resolves the {@code javascript.execute.target} property, accepting the
- * target names with or without the leading underscore.
+ * Resolves the {@code javascript.execute.target} property to one of the
+ * three documented values, falling back to {@code auto}. An unrecognized
+ * value is logged rather than silently accepted, so a typo is diagnosable
+ * instead of looking like the default was chosen deliberately.
*/
private String executeTarget() {
String target = Display.getInstance().getProperty("javascript.execute.target", EXECUTE_TARGET_AUTO);
- if (EXECUTE_TARGET_SELF.equals(target) || "self".equals(target)) {
+ if (EXECUTE_TARGET_SELF.equals(target)) {
return EXECUTE_TARGET_SELF;
}
- if (EXECUTE_TARGET_BLANK.equals(target) || "blank".equals(target) || "new".equals(target)) {
+ if (EXECUTE_TARGET_BLANK.equals(target)) {
return EXECUTE_TARGET_BLANK;
}
+ if (!EXECUTE_TARGET_AUTO.equals(target)) {
+ _log("javascript.execute.target: unrecognized value '" + target
+ + "', expected auto, _blank or _self. Using auto.");
+ }
return EXECUTE_TARGET_AUTO;
}
@@ -6942,7 +6948,10 @@ private static boolean isExternalUrl(String url) {
return false;
}
}
- return !"file".equals(url.substring(0, colon).toLowerCase());
+ // equalsIgnoreCase rather than toLowerCase(): case folding a scheme
+ // through the default locale turns "FILE" into "fIle" under a Turkish
+ // locale and would classify a file: URL as external.
+ return !"file".equalsIgnoreCase(url.substring(0, colon));
}
/**
@@ -7045,7 +7054,6 @@ public void execute(String url) {
String buttonText = null;
//String icon = null;
- final String furl = url;
if (useBlobHandler) {
//popover.setContents("");
buttonText = "Click to Download "+(fileName!=null?fileName:"File");
@@ -7063,7 +7071,7 @@ public void execute(String url) {
// isExternalUrl() is true for data: as well -- it does carry a
// scheme -- so that branch MUST stay ahead of this one or data:
// URLs would navigate instead of downloading.
- openExternalUrl(furl);
+ openExternalUrl(url);
return;
} else {
// A local/storage path we could not turn into a Blob: exists() said
@@ -7073,8 +7081,8 @@ public void execute(String url) {
// would point the page at a path the server does not serve and
// unload the app instead of downloading anything. Best effort in a
// new window, which is what this port did before.
- _log("execute(): no downloadable content for " + furl);
- openUrlOnMainThread(furl, false, false);
+ _log("execute(): no downloadable content for " + url);
+ openUrlOnMainThread(url, false, false);
return;
}
final Runnable startDownload = new Runnable() {
@@ -7084,7 +7092,7 @@ public void run() {
fireSaveBlobHandler();
} else {
_log("Opening URL in new window");
- openUrlOnMainThread(furl, false, false);
+ openUrlOnMainThread(url, false, false);
}
}
};
From b23be46522b7e30f1bc947738d9b6039ff80f6b9 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 07:43:06 +0300
Subject: [PATCH 10/44] JS port: show the real host in the confirmation Sheet,
and four review fixes
The Sheet's URL display could hide where a link actually goes. It stripped
the scheme and truncated the first 37 characters, so
https://www.paypal.com@evil.example/ was shown as a trusted looking prefix
plus an ellipsis while confirming opened evil.example. Parse the authority
instead: drop the user info, which an attacker picks freely and can spell to
read like a trusted host, and when the host itself is over-long keep its END,
since the registrable domain is the rightmost labels. Verified against the
user-info attack, long-host, port, path-only and non-hierarchical cases.
Make the javascript: and data: branches of execute() case-insensitive via a
hasScheme() helper. Schemes are case-insensitive per the URI rules, and
isExternalUrl() already treated them that way, so JavaScript: previously
slipped past its branch and was handled as an external link.
Gate the download path's popup leg on isGestureBackedHookAvailable(). Firing
the blob handler is a download, which browsers gate on engagement, so an
interval-only hook suffices there; the no-blob-handler leg opens a popup and
needs a real gesture, exactly as openExternalUrl() does.
Say "Open this link?" rather than promising a new window when the Sheet is
reached under auto or _self, where it is a compatibility fallback the caller
never asked for.
Reword the comment above the blob lookup, which read as though isExternalUrl()
returned false for data: when it means the negation excludes it.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 105 ++++++++++++++----
1 file changed, 84 insertions(+), 21 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 40db247686a..14f9a2a855e 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6836,22 +6836,55 @@ static String shortenUrlForDisplay(String url) {
if (url == null) {
return "";
}
- String shortened = url;
- int schemeEnd = shortened.indexOf("://");
- if (schemeEnd >= 0) {
- shortened = shortened.substring(schemeEnd + 3);
- }
- if (shortened.length() > MAX_DISPLAY_URL_LENGTH) {
- int slash = shortened.indexOf('/');
- if (slash > 0 && slash <= MAX_DISPLAY_URL_LENGTH) {
- // Host fits: keep it whole and stand in for the path.
- shortened = shortened.substring(0, slash) + "/" + DISPLAY_URL_ELLIPSIS;
- } else {
- shortened = shortened.substring(0, MAX_DISPLAY_URL_LENGTH - DISPLAY_URL_ELLIPSIS.length())
- + DISPLAY_URL_ELLIPSIS;
+ int schemeEnd = url.indexOf("://");
+ if (schemeEnd < 0) {
+ // No authority to protect (mailto:, tel:, a bare storage path).
+ return ellipsizeTail(url);
+ }
+ String rest = url.substring(schemeEnd + 3);
+ int authorityEnd = rest.length();
+ for (int i = 0; i < rest.length(); i++) {
+ char c = rest.charAt(i);
+ if (c == '/' || c == '?' || c == '#') {
+ authorityEnd = i;
+ break;
}
}
- return shortened;
+ String authority = rest.substring(0, authorityEnd);
+ // Everything before the last '@' is user info. An attacker picks it
+ // freely and can spell it to read like a trusted host, so showing it --
+ // or truncating the display in the middle of it -- would let
+ // https://www.paypal.com@evil.example/ be confirmed as "www.paypal.com".
+ // What confirming actually opens is the host, so show only that.
+ int at = authority.lastIndexOf('@');
+ if (at >= 0) {
+ authority = authority.substring(at + 1);
+ }
+ if (authority.length() == 0) {
+ // Scheme-relative or path-only, e.g. imdb:///find?q=godfather.
+ return ellipsizeTail(rest);
+ }
+ if (authority.length() > MAX_DISPLAY_URL_LENGTH) {
+ // Keep the END of an over-long host: the registrable domain is the
+ // rightmost labels, so dropping the front is what preserves the
+ // part that decides where the link goes.
+ authority = DISPLAY_URL_ELLIPSIS + authority.substring(
+ authority.length() - (MAX_DISPLAY_URL_LENGTH - DISPLAY_URL_ELLIPSIS.length()));
+ }
+ // A lone trailing "/" is not worth an ellipsis.
+ boolean hasPath = rest.length() - authorityEnd > 1;
+ return hasPath ? authority + "/" + DISPLAY_URL_ELLIPSIS : authority;
+ }
+
+ /**
+ * Trims a string with no authority to protect down to the display cap.
+ */
+ private static String ellipsizeTail(String value) {
+ if (value.length() <= MAX_DISPLAY_URL_LENGTH) {
+ return value;
+ }
+ return value.substring(0, MAX_DISPLAY_URL_LENGTH - DISPLAY_URL_ELLIPSIS.length())
+ + DISPLAY_URL_ELLIPSIS;
}
/**
@@ -6924,6 +6957,19 @@ private boolean isGestureBackedHookAvailable() {
* URLs. {@code file:} is the exception: like the bare paths that carry no
* scheme at all, it names local content.
*/
+ /**
+ * True when {@code url} carries exactly {@code scheme}, compared without
+ * regard to case since URI schemes are case-insensitive. Guards the
+ * {@code javascript:} and {@code data:} branches of {@link #execute(String)},
+ * which would otherwise let {@code JavaScript:} slip through to the
+ * external-link path.
+ */
+ private static boolean hasScheme(String url, String scheme) {
+ return url.length() > scheme.length()
+ && url.charAt(scheme.length()) == ':'
+ && url.regionMatches(true, 0, scheme, 0, scheme.length());
+ }
+
private static boolean isExternalUrl(String url) {
if (url.startsWith("//")) {
// Protocol relative -- the page's own scheme applies.
@@ -6986,7 +7032,14 @@ public void run() {
});
return;
}
- createConfirmationSheet("Open Link", "Open this link in a new window?",
+ // Only _blank actually asked for a new window. Under auto or _self the
+ // Sheet is a compatibility fallback the caller never requested, so
+ // promising a new window there would describe behavior they did not
+ // choose.
+ String prompt = EXECUTE_TARGET_BLANK.equals(target)
+ ? "Open this link in a new window?"
+ : "Open this link?";
+ createConfirmationSheet("Open Link", prompt,
shortenUrlForDisplay(url), new Runnable() {
@Override
public void run() {
@@ -7019,7 +7072,7 @@ public void run() {
@Override
public void execute(String url) {
- if (url.startsWith("javascript:")) {
+ if (hasScheme(url, "javascript")) {
String cmd = url.substring(url.indexOf(":")+1);
eval_(cmd);
return;
@@ -7028,9 +7081,10 @@ public void execute(String url) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- // Only a local path can name storage content to wrap in a Blob. A
- // data: URL carries its own payload and is handled further down;
- // isExternalUrl already excludes it, since it has a scheme.
+ // Only a local path can name storage content to wrap in a Blob.
+ // isExternalUrl() is false for exactly those, and true for everything
+ // with a scheme -- data: included, which carries its own payload and is
+ // claimed by its own branch further down.
if (!isExternalUrl(url)) {
if (exists(url)) {
try {
@@ -7059,7 +7113,7 @@ public void execute(String url) {
buttonText = "Click to Download "+(fileName!=null?fileName:"File");
nativeButton.setText(buttonText);
nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
- } else if (url.startsWith("data:")) {
+ } else if (hasScheme(url, "data")) {
registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, url);
//popover.setContents("");
buttonText = "Click to Download "+(fileName!=null?fileName:"File");
@@ -7096,7 +7150,16 @@ public void run() {
}
}
};
- if (isBacksideHookAvailable()) {
+ // Firing the registered blob handler is a download, which browsers gate
+ // on engagement rather than transient activation, so an interval-only
+ // hook is good enough for it. The no-blob-handler leg opens a popup
+ // instead, and that does need a real gesture -- draining it from a timer
+ // would let the browser block it with no Sheet shown. Pick the gate that
+ // matches the leg this run will actually take.
+ boolean hookAvailable = fuseBlobHandler
+ ? isBacksideHookAvailable()
+ : isGestureBackedHookAvailable();
+ if (hookAvailable) {
addBacksideHook(new JSRunnable() {
@Override
public void run() {
From 3621f635c5ffc2eb4224198c4604d1c6110876d9 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 07:48:45 +0300
Subject: [PATCH 11/44] JS port: parse protocol-relative authorities, confirm
failed local opens
Two more review findings, both regressions this branch introduced.
shortenUrlForDisplay() found an authority only via indexOf("://"), but
isExternalUrl() accepts protocol-relative //host/path, so those reached the
Sheet and fell to ellipsizeTail() -- reopening the exact hole the previous
commit closed, since
//www.paypal.com.secure.verify.session@evil.example/pay would have displayed
as its trusted-looking prefix. Treat a leading // as authority bearing.
The failed local download branch called openUrlOnMainThread() straight from
the EDT, where the browser no longer sees a gesture, so the popup could be
blocked with no confirmation and no recovery -- before this branch that case
went through the hook/Sheet logic. Extract that logic as
openInNewWindowWithConfirmation() and route both it and openExternalUrl()
through it, so there is one gesture-aware popup path rather than two that
can drift.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 62 ++++++++++++-------
1 file changed, 40 insertions(+), 22 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 14f9a2a855e..2560da70d63 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6836,12 +6836,20 @@ static String shortenUrlForDisplay(String url) {
if (url == null) {
return "";
}
- int schemeEnd = url.indexOf("://");
- if (schemeEnd < 0) {
- // No authority to protect (mailto:, tel:, a bare storage path).
- return ellipsizeTail(url);
+ String rest;
+ if (url.startsWith("//")) {
+ // Protocol relative. isExternalUrl() accepts these, so they reach
+ // the Sheet and carry an authority that needs the same parsing --
+ // indexOf("://") does not find one.
+ rest = url.substring(2);
+ } else {
+ int schemeEnd = url.indexOf("://");
+ if (schemeEnd < 0) {
+ // No authority to protect (mailto:, tel:, a bare storage path).
+ return ellipsizeTail(url);
+ }
+ rest = url.substring(schemeEnd + 3);
}
- String rest = url.substring(schemeEnd + 3);
int authorityEnd = rest.length();
for (int i = 0; i < rest.length(); i++) {
char c = rest.charAt(i);
@@ -7016,13 +7024,28 @@ && openUrlOnMainThread(url, false, true)) {
}
// _blank, or _self / auto whose page-side script never ran because the
// host bundle predates the eval-on-main handler -- degrade rather than
- // leave execute() with no effect at all:
- // a popup only survives inside a live user gesture, so ride the backside
- // hook when a gesture-backed one is pending and otherwise ask the user,
- // whose tap on the Sheet becomes the gesture. Note this deliberately
- // ignores an interval-only hook: draining from a timer would let the
- // browser block the popup with nothing shown, which is worse than the
- // Sheet that _blank promises.
+ // leave execute() with no effect at all.
+ //
+ // Only _blank actually asked for a new window. Under auto or _self the
+ // Sheet is a compatibility fallback the caller never requested, so
+ // promising a new window there would describe behavior they did not
+ // choose.
+ openInNewWindowWithConfirmation(url, EXECUTE_TARGET_BLANK.equals(target)
+ ? "Open this link in a new window?"
+ : "Open this link?");
+ }
+
+ /**
+ * Opens {@code url} in a new window/tab the gesture-aware way: a popup only
+ * survives inside a live user gesture, so ride a backside hook when a
+ * gesture-backed one is pending and otherwise ask the user, whose tap on the
+ * Sheet becomes the gesture.
+ *
+ *
An interval-only hook is deliberately not enough here. Draining from a
+ * timer would let the browser block the popup with nothing shown, which is
+ * worse than the Sheet.
+ */
+ private void openInNewWindowWithConfirmation(final String url, String prompt) {
if (isGestureBackedHookAvailable()) {
addBacksideHook(new JSRunnable() {
@Override
@@ -7032,13 +7055,6 @@ public void run() {
});
return;
}
- // Only _blank actually asked for a new window. Under auto or _self the
- // Sheet is a compatibility fallback the caller never requested, so
- // promising a new window there would describe behavior they did not
- // choose.
- String prompt = EXECUTE_TARGET_BLANK.equals(target)
- ? "Open this link in a new window?"
- : "Open this link?";
createConfirmationSheet("Open Link", prompt,
shortenUrlForDisplay(url), new Runnable() {
@Override
@@ -7050,7 +7066,7 @@ public void run() {
// install a backside hook though (pointer handling calls
// installBacksideHooksInUserInteraction), so queue the open on
// it and let the drain perform it inside the gesture. Same
- // shape as the download confirmation below.
+ // shape as the download confirmation in execute().
addBacksideHook(new JSRunnable() {
@Override
public void run() {
@@ -7134,9 +7150,11 @@ public void execute(String url) {
// off the external-link target policy -- under auto or _self that
// would point the page at a path the server does not serve and
// unload the app instead of downloading anything. Best effort in a
- // new window, which is what this port did before.
+ // new window, which is what this port did before -- and through the
+ // gesture-aware path, since opening straight from the EDT would let
+ // the browser block the popup with no confirmation shown.
_log("execute(): no downloadable content for " + url);
- openUrlOnMainThread(url, false, false);
+ openInNewWindowWithConfirmation(url, "Open this link?");
return;
}
final Runnable startDownload = new Runnable() {
From bc1745436bc34496f0042abf3344dede57734074 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 07:53:33 +0300
Subject: [PATCH 12/44] JS port: parse slashless special-scheme authorities,
one popup per gesture
Two more review findings.
Browsers parse a special scheme without slashes -- http:host/path, and even
http:/host/path -- with host as the authority, so
http:trusted-looking-prefix@evil.example/path reached the Sheet and displayed
its user info while confirming opened evil.example. shortenUrlForDisplay()
now recognizes that form for the WHATWG special schemes. This is the third
authority shape the shortener had to learn; the earlier two were "://" and
protocol-relative.
Reserve a pending gesture for one popup. backsideHooksSemaphore counts
pending drains, not unconsumed popup authorization: two _blank executions
queued behind the same pointer event both saw it raised, drained in one
batch, and the first window.open() consumed the activation so the browser
blocked the second with nothing shown. Track queued-but-undrained popups and
send everything past the first to the Sheet, which earns it its own gesture.
Also stop reporting a blocked popup as success. The plain new-window script
now throws when window.open() returns null, which is the only evidence
available page-side, so openUrlOnMainThread() returns false and the caller
logs it rather than assuming it worked.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 62 ++++++++++++++++---
1 file changed, 55 insertions(+), 7 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 2560da70d63..60be37dd067 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6806,7 +6806,11 @@ private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fall
+ "var cn1w = (!cn1a || cn1a.isActive) ? window.open(" + literal + ", '_blank') : null;"
+ "if (!cn1w) { window.location.href = " + literal + "; }";
} else {
- script = guard + "window.open(" + literal + ", '_blank');";
+ // Report a blocked popup instead of returning as if it opened. The
+ // page-side null is the only evidence available -- there is no
+ // callback -- so turn it into a throw the host call propagates.
+ script = guard + "var cn1w = window.open(" + literal + ", '_blank');"
+ + "if (!cn1w) { throw new Error('cn1: popup blocked'); }";
}
try {
eval_(script);
@@ -6844,11 +6848,24 @@ static String shortenUrlForDisplay(String url) {
rest = url.substring(2);
} else {
int schemeEnd = url.indexOf("://");
- if (schemeEnd < 0) {
- // No authority to protect (mailto:, tel:, a bare storage path).
- return ellipsizeTail(url);
+ if (schemeEnd >= 0) {
+ rest = url.substring(schemeEnd + 3);
+ } else {
+ int colon = url.indexOf(':');
+ if (colon > 0 && isSpecialScheme(url.substring(0, colon))) {
+ // A special scheme tolerates missing slashes: browsers parse
+ // http:host/path, and even http:/host/path, with host as the
+ // authority. Without this the slashless form would skip the
+ // parsing below and display its leading user info.
+ rest = url.substring(colon + 1);
+ while (rest.startsWith("/")) {
+ rest = rest.substring(1);
+ }
+ } else {
+ // No authority to protect (mailto:, tel:, a bare path).
+ return ellipsizeTail(url);
+ }
}
- rest = url.substring(schemeEnd + 3);
}
int authorityEnd = rest.length();
for (int i = 0; i < rest.length(); i++) {
@@ -6884,6 +6901,18 @@ static String shortenUrlForDisplay(String url) {
return hasPath ? authority + "/" + DISPLAY_URL_ELLIPSIS : authority;
}
+ /**
+ * The WHATWG URL "special" schemes, which browsers parse with an authority
+ * even when the {@code //} is missing.
+ */
+ private static boolean isSpecialScheme(String scheme) {
+ return "http".equalsIgnoreCase(scheme)
+ || "https".equalsIgnoreCase(scheme)
+ || "ws".equalsIgnoreCase(scheme)
+ || "wss".equalsIgnoreCase(scheme)
+ || "ftp".equalsIgnoreCase(scheme);
+ }
+
/**
* Trims a string with no authority to protect down to the display cap.
*/
@@ -6951,6 +6980,18 @@ private boolean isGestureBackedHookAvailable() {
return backsideHooksSemaphore > 0;
}
+ /**
+ * Popup opens queued on a backside hook but not yet drained.
+ *
+ *
The semaphore counts pending drains, not unconsumed popup
+ * authorization. One gesture authorizes ONE {@code window.open()}: the
+ * first consumes the transient activation, so a second queued behind the
+ * same gesture drains in the same batch and is blocked. Only ever reserve a
+ * pending gesture for a single popup and send the rest to the Sheet, which
+ * earns them a gesture of their own.
+ */
+ private int pendingPopupOpens;
+
/**
* True when the URL names something the browser itself can hand off, as
* opposed to a path into local storage that {@link #execute(String)} is
@@ -7046,11 +7087,18 @@ && openUrlOnMainThread(url, false, true)) {
* worse than the Sheet.
*/
private void openInNewWindowWithConfirmation(final String url, String prompt) {
- if (isGestureBackedHookAvailable()) {
+ if (isGestureBackedHookAvailable() && pendingPopupOpens == 0) {
+ pendingPopupOpens++;
addBacksideHook(new JSRunnable() {
@Override
public void run() {
- openUrlOnMainThread(url, false, false);
+ try {
+ if (!openUrlOnMainThread(url, false, false)) {
+ _log("execute(): popup blocked for " + url);
+ }
+ } finally {
+ pendingPopupOpens--;
+ }
}
});
return;
From 57ea44075c41d5ee41aeed638e5e1f13f50ef015 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 07:59:12 +0300
Subject: [PATCH 13/44] JS port: hold the popup reservation until the gesture's
drains all expire
installBacksideHooksInUserInteraction() schedules three drains off one
interaction (300ms, 1500ms, 5000ms), and the semaphore stays positive for all
of them. Releasing the reservation when the first drain ran therefore left the
other two looking like fresh gestures: a _blank execute() in that window
queued a second popup whose activation the first had already consumed, and it
could be blocked with no Sheet shown.
Hold the reservation for the whole burst instead -- release it where the
semaphore falls back to zero, which is the point every drain from that
interaction has run. A boolean replaces the queued-popup counter, since what
needs tracking is one popup per gesture rather than per pending drain.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 40 ++++++++++++-------
1 file changed, 25 insertions(+), 15 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 60be37dd067..1e4fae80f2f 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -663,6 +663,12 @@ private void runBacksideHooksInTimeout(int timeout) {
public void onTimer() {
backsideHooksSemaphore--;
//_log("Decrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
+ if (backsideHooksSemaphore <= 0) {
+ // Every delayed drain scheduled off that interaction has
+ // run, so whatever activation it granted is spent. The next
+ // popup has to earn a gesture of its own.
+ popupReservedForGesture = false;
+ }
runBacksideHooks();
}
}, timeout);
@@ -6981,16 +6987,21 @@ private boolean isGestureBackedHookAvailable() {
}
/**
- * Popup opens queued on a backside hook but not yet drained.
+ * Whether the gesture currently represented by {@link #backsideHooksSemaphore}
+ * has already had a popup queued against it.
*
*
The semaphore counts pending drains, not unconsumed popup
- * authorization. One gesture authorizes ONE {@code window.open()}: the
- * first consumes the transient activation, so a second queued behind the
- * same gesture drains in the same batch and is blocked. Only ever reserve a
- * pending gesture for a single popup and send the rest to the Sheet, which
- * earns them a gesture of their own.
+ * authorization. One gesture authorizes ONE {@code window.open()} -- the
+ * first consumes the transient activation -- yet
+ * {@code installBacksideHooksInUserInteraction()} schedules three drains
+ * (300ms, 1500ms, 5000ms) off a single interaction. Releasing the
+ * reservation when the first of those runs would let a later execute() read
+ * one of the remaining drains as a fresh gesture and queue a popup whose
+ * activation is already spent. So hold the reservation until the whole burst
+ * has expired, i.e. the semaphore falls back to zero, and send everything
+ * else to the Sheet, which earns a gesture of its own.
*/
- private int pendingPopupOpens;
+ private boolean popupReservedForGesture;
/**
* True when the URL names something the browser itself can hand off, as
@@ -7087,17 +7098,16 @@ && openUrlOnMainThread(url, false, true)) {
* worse than the Sheet.
*/
private void openInNewWindowWithConfirmation(final String url, String prompt) {
- if (isGestureBackedHookAvailable() && pendingPopupOpens == 0) {
- pendingPopupOpens++;
+ if (isGestureBackedHookAvailable() && !popupReservedForGesture) {
+ // Held until the gesture's whole drain burst expires -- see the
+ // field. Not released here, where the first drain would free it
+ // while later drains from the same interaction are still pending.
+ popupReservedForGesture = true;
addBacksideHook(new JSRunnable() {
@Override
public void run() {
- try {
- if (!openUrlOnMainThread(url, false, false)) {
- _log("execute(): popup blocked for " + url);
- }
- } finally {
- pendingPopupOpens--;
+ if (!openUrlOnMainThread(url, false, false)) {
+ _log("execute(): popup blocked for " + url);
}
}
});
From 14983120e16121d7e9632e3640460f21fbe95c45 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:01:50 +0300
Subject: [PATCH 14/44] JS port: reattach isExternalUrl's javadoc, stop
double-logging open failures
Inserting hasScheme() ahead of isExternalUrl() dropped it between that
method's javadoc and its declaration, so the doc bound to hasScheme -- which
then carried two blocks, the first dangling -- and isExternalUrl was left
undocumented. Move it back.
openUrlOnMainThread() already logs why an open failed, and returns false for
any failure rather than only a blocked popup, so the caller's "popup blocked"
line was both a duplicate and a guess at the cause. Drop it, and reword the
confirmed-open message to record only that the confirmed attempt was the end
of the line rather than asserting which failure it hit.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 48 +++++++++----------
1 file changed, 24 insertions(+), 24 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 1e4fae80f2f..6c9bd7700f2 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7003,6 +7003,19 @@ private boolean isGestureBackedHookAvailable() {
*/
private boolean popupReservedForGesture;
+ /**
+ * True when {@code url} carries exactly {@code scheme}, compared without
+ * regard to case since URI schemes are case-insensitive. Guards the
+ * {@code javascript:} and {@code data:} branches of {@link #execute(String)},
+ * which would otherwise let {@code JavaScript:} slip through to the
+ * external-link path.
+ */
+ private static boolean hasScheme(String url, String scheme) {
+ return url.length() > scheme.length()
+ && url.charAt(scheme.length()) == ':'
+ && url.regionMatches(true, 0, scheme, 0, scheme.length());
+ }
+
/**
* True when the URL names something the browser itself can hand off, as
* opposed to a path into local storage that {@link #execute(String)} is
@@ -7017,19 +7030,6 @@ private boolean isGestureBackedHookAvailable() {
* URLs. {@code file:} is the exception: like the bare paths that carry no
* scheme at all, it names local content.
*/
- /**
- * True when {@code url} carries exactly {@code scheme}, compared without
- * regard to case since URI schemes are case-insensitive. Guards the
- * {@code javascript:} and {@code data:} branches of {@link #execute(String)},
- * which would otherwise let {@code JavaScript:} slip through to the
- * external-link path.
- */
- private static boolean hasScheme(String url, String scheme) {
- return url.length() > scheme.length()
- && url.charAt(scheme.length()) == ':'
- && url.regionMatches(true, 0, scheme, 0, scheme.length());
- }
-
private static boolean isExternalUrl(String url) {
if (url.startsWith("//")) {
// Protocol relative -- the page's own scheme applies.
@@ -7106,9 +7106,10 @@ private void openInNewWindowWithConfirmation(final String url, String prompt) {
addBacksideHook(new JSRunnable() {
@Override
public void run() {
- if (!openUrlOnMainThread(url, false, false)) {
- _log("execute(): popup blocked for " + url);
- }
+ // openUrlOnMainThread reports the reason on failure --
+ // popup blocked or no page-side channel -- so do not
+ // second-guess it with a duplicate line here.
+ openUrlOnMainThread(url, false, false);
}
});
return;
@@ -7129,14 +7130,13 @@ public void run() {
@Override
public void run() {
if (!openUrlOnMainThread(url, false, false)) {
- // The user confirmed and we still could not open.
- // Only reachable on a host bundle with no
- // eval-on-main handler, where the worker has no
- // page-side channel at all -- there is nothing
- // further to fall back to, and re-showing the Sheet
- // would just loop. Say so plainly in the log.
- _log("execute(): confirmed open failed, no page-side"
- + " channel available for " + url);
+ // The user confirmed and it still did not open.
+ // openUrlOnMainThread has already logged why; note
+ // only that this was the confirmed attempt, which
+ // is the end of the line -- re-showing the Sheet
+ // from here would just loop.
+ _log("execute(): confirmed open did not succeed for "
+ + url);
}
}
});
From 7d0514f9de8a4886a0714f82df31a18977ab0b5a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:07:07 +0300
Subject: [PATCH 15/44] JS port: reserve popups per interaction, stop the
authority at a backslash
Two more review findings, both refining earlier fixes of mine.
The popup reservation keyed off the shared semaphore reaching zero, which
aggregates drains from every gesture: two link taps under five seconds apart
raise it again before the first one's drains have run, so it never reaches
zero, the reservation never clears and the second tap gets a Sheet despite
owning a real gesture. Continued interaction could hold that state
indefinitely. Track the interaction itself instead --
installBacksideHooksInUserInteraction() bumps a generation counter and the
reservation records which generation spent its popup. Neither "the first drain
ran" nor "the semaphore reached zero" marks a gesture boundary; comparing
generations does, and nothing needs clearing.
Special URLs also accept "\" wherever they accept "/", so
https://evil.example\ read as one over-long
authority and the keep-the-tail rule showed the attacker's suffix rather than
the host. End the authority at a backslash too, for special schemes only --
imdb://host\notspecial keeps its backslash.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 68 +++++++++++--------
1 file changed, 41 insertions(+), 27 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 6c9bd7700f2..3c62f331c14 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -663,12 +663,6 @@ private void runBacksideHooksInTimeout(int timeout) {
public void onTimer() {
backsideHooksSemaphore--;
//_log("Decrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
- if (backsideHooksSemaphore <= 0) {
- // Every delayed drain scheduled off that interaction has
- // run, so whatever activation it granted is spent. The next
- // popup has to earn a gesture of its own.
- popupReservedForGesture = false;
- }
runBacksideHooks();
}
}, timeout);
@@ -746,6 +740,9 @@ private static int safariBacksideHookDelay() {
private native static int _safariBacksideHookDelay();
private void installBacksideHooksInUserInteraction() {
+ // A distinct interaction, so a popup reserved against the previous one
+ // no longer applies -- see popupReservedForGeneration.
+ gestureGeneration++;
if (isIOS() || isSafari()) {
debugLog("Installing backside hooks with delay "+safariBacksideHookDelay());
runBacksideHooksInTimeout(safariBacksideHookDelay());
@@ -6847,15 +6844,23 @@ static String shortenUrlForDisplay(String url) {
return "";
}
String rest;
+ // Special URLs also accept "\\" wherever they accept "/", so the
+ // authority ends there too -- otherwise https://evil.example\\ reads as one over-long authority and the tail
+ // rule below would show the attacker's suffix instead of the host.
+ boolean special;
if (url.startsWith("//")) {
// Protocol relative. isExternalUrl() accepts these, so they reach
// the Sheet and carry an authority that needs the same parsing --
- // indexOf("://") does not find one.
+ // indexOf("://") does not find one. The page's own scheme applies,
+ // which for a served app is http(s), so treat it as special.
rest = url.substring(2);
+ special = true;
} else {
int schemeEnd = url.indexOf("://");
if (schemeEnd >= 0) {
rest = url.substring(schemeEnd + 3);
+ special = isSpecialScheme(url.substring(0, schemeEnd));
} else {
int colon = url.indexOf(':');
if (colon > 0 && isSpecialScheme(url.substring(0, colon))) {
@@ -6864,9 +6869,11 @@ static String shortenUrlForDisplay(String url) {
// authority. Without this the slashless form would skip the
// parsing below and display its leading user info.
rest = url.substring(colon + 1);
- while (rest.startsWith("/")) {
+ while (rest.startsWith("/") || rest.startsWith("\\")) {
rest = rest.substring(1);
}
+ // Only reached when the scheme is special.
+ special = true;
} else {
// No authority to protect (mailto:, tel:, a bare path).
return ellipsizeTail(url);
@@ -6876,7 +6883,7 @@ static String shortenUrlForDisplay(String url) {
int authorityEnd = rest.length();
for (int i = 0; i < rest.length(); i++) {
char c = rest.charAt(i);
- if (c == '/' || c == '?' || c == '#') {
+ if (c == '/' || c == '?' || c == '#' || (special && c == '\\')) {
authorityEnd = i;
break;
}
@@ -6987,21 +6994,28 @@ private boolean isGestureBackedHookAvailable() {
}
/**
- * Whether the gesture currently represented by {@link #backsideHooksSemaphore}
- * has already had a popup queued against it.
+ * Identifies the user interaction currently in play. Bumped by
+ * {@link #installBacksideHooksInUserInteraction()}, which runs once per
+ * pointer or key event.
+ */
+ private int gestureGeneration;
+
+ /**
+ * The {@link #gestureGeneration} that has already had a popup queued
+ * against it, or -1.
*
- *
The semaphore counts pending drains, not unconsumed popup
- * authorization. One gesture authorizes ONE {@code window.open()} -- the
- * first consumes the transient activation -- yet
- * {@code installBacksideHooksInUserInteraction()} schedules three drains
- * (300ms, 1500ms, 5000ms) off a single interaction. Releasing the
- * reservation when the first of those runs would let a later execute() read
- * one of the remaining drains as a fresh gesture and queue a popup whose
- * activation is already spent. So hold the reservation until the whole burst
- * has expired, i.e. the semaphore falls back to zero, and send everything
- * else to the Sheet, which earns a gesture of its own.
+ *
One interaction authorizes ONE {@code window.open()}: the first
+ * consumes the transient activation, so anything else queued behind the
+ * same interaction is blocked. The count of pending drains cannot express
+ * this -- a single interaction schedules three (300ms, 1500ms, 5000ms), and
+ * a second interaction within five seconds raises the shared semaphore
+ * again before the first one's drains have run, so neither "the first drain
+ * ran" nor "the semaphore reached zero" marks a gesture boundary. Comparing
+ * generations does: a genuinely new interaction gets its own popup, while
+ * everything else in the same one goes to the Sheet and earns a gesture of
+ * its own.
*/
- private boolean popupReservedForGesture;
+ private int popupReservedForGeneration = -1;
/**
* True when {@code url} carries exactly {@code scheme}, compared without
@@ -7098,11 +7112,11 @@ && openUrlOnMainThread(url, false, true)) {
* worse than the Sheet.
*/
private void openInNewWindowWithConfirmation(final String url, String prompt) {
- if (isGestureBackedHookAvailable() && !popupReservedForGesture) {
- // Held until the gesture's whole drain burst expires -- see the
- // field. Not released here, where the first drain would free it
- // while later drains from the same interaction are still pending.
- popupReservedForGesture = true;
+ if (isGestureBackedHookAvailable() && popupReservedForGeneration != gestureGeneration) {
+ // Tied to the interaction rather than to any drain of it -- see the
+ // field. Never cleared; the next interaction simply carries a
+ // different generation.
+ popupReservedForGeneration = gestureGeneration;
addBacksideHook(new JSRunnable() {
@Override
public void run() {
From 3dafacf9c7d183b275feabb74b1b46bdf6ef53ed Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:12:42 +0300
Subject: [PATCH 16/44] JS port: normalize extra authority separators, confirm
a blocked reserved popup
Two more review findings.
Browsers ignore any number of separators after a special scheme, so
https:////user@evil.example/path opens evil.example -- but the parser stripped
only the first "://", the authority scan then stopped on the leftover slash,
read an empty authority and fell back to displaying the raw trusted-looking
prefix. Skip the extras for special URLs before scanning. That also subsumes
the slashless branch's own strip loop, so http:host and http:/host now share
one normalization.
The one reserved gesture-backed popup can still be blocked by a browser
setting, an extension, or activation that expired before the drain, and the
callback discarded that result -- so _blank could leave the user with a dead
click and no Sheet, which is what the explicit blocked-popup result was added
to expose. Route the failure back through the confirmation flow instead. It
cannot loop: this generation's reservation is already spent, so re-entering
lands on the Sheet. Hops to the EDT first, since the callback runs on a hook
drain.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 36 +++++++++++++++----
1 file changed, 30 insertions(+), 6 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 3c62f331c14..6a9bb3ef1a6 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6869,10 +6869,8 @@ static String shortenUrlForDisplay(String url) {
// authority. Without this the slashless form would skip the
// parsing below and display its leading user info.
rest = url.substring(colon + 1);
- while (rest.startsWith("/") || rest.startsWith("\\")) {
- rest = rest.substring(1);
- }
- // Only reached when the scheme is special.
+ // Only reached when the scheme is special; the separator
+ // skip below covers http:/host as well as http:host.
special = true;
} else {
// No authority to protect (mailto:, tel:, a bare path).
@@ -6880,6 +6878,18 @@ static String shortenUrlForDisplay(String url) {
}
}
}
+ if (special) {
+ // Browsers ignore any number of separators after the scheme, so
+ // https:////host and https:/\\/host both open host. Skip them, or
+ // the scan below stops on the leftover separator, reads an empty
+ // authority and falls back to showing the raw prefix.
+ while (rest.startsWith("/") || rest.startsWith("\\")) {
+ rest = rest.substring(1);
+ }
+ if (rest.length() == 0) {
+ return ellipsizeTail(url);
+ }
+ }
int authorityEnd = rest.length();
for (int i = 0; i < rest.length(); i++) {
char c = rest.charAt(i);
@@ -7111,7 +7121,7 @@ && openUrlOnMainThread(url, false, true)) {
* timer would let the browser block the popup with nothing shown, which is
* worse than the Sheet.
*/
- private void openInNewWindowWithConfirmation(final String url, String prompt) {
+ private void openInNewWindowWithConfirmation(final String url, final String prompt) {
if (isGestureBackedHookAvailable() && popupReservedForGeneration != gestureGeneration) {
// Tied to the interaction rather than to any drain of it -- see the
// field. Never cleared; the next interaction simply carries a
@@ -7123,7 +7133,21 @@ public void run() {
// openUrlOnMainThread reports the reason on failure --
// popup blocked or no page-side channel -- so do not
// second-guess it with a duplicate line here.
- openUrlOnMainThread(url, false, false);
+ if (!openUrlOnMainThread(url, false, false)) {
+ // A browser setting, an extension or an activation that
+ // expired before the drain can still block the one
+ // reserved attempt. Fall back to asking rather than
+ // leaving the caller with a dead click. Re-entering is
+ // safe and cannot loop: this generation's reservation is
+ // already spent, so it lands on the Sheet. Hop to the
+ // EDT first -- this runs on a hook drain.
+ callSerially(new Runnable() {
+ @Override
+ public void run() {
+ openInNewWindowWithConfirmation(url, prompt);
+ }
+ });
+ }
}
});
return;
From 2939d630cc278cb9111a4be978918dfcff605c53 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:19:07 +0300
Subject: [PATCH 17/44] JS port: normalize the URL once at entry, sever
window.opener
Leading whitespace was the seventh authority shape the display parser had to
learn -- after "://", protocol-relative, slashless, backslash and extra
separators -- so fix the cause rather than add a seventh branch.
execute() now normalizes once, the way a browser does (strip leading and
trailing C0 controls and spaces, remove every tab and newline), and
classification, the Sheet's display and the string handed to the page all work
from that single form. Parsing the raw input separately in each place is what
let " https:host@evil.example/" be read off a scheme of " https" and displayed
as its trusted-looking prefix while the browser navigated to evil.example.
Also sever window.opener on windows we open, so the opened page cannot
navigate the app's page (reverse tabnabbing). Deliberately not the noopener
window feature: that makes window.open() return null in every browser, which
would destroy the only signal for telling a blocked popup from a successful
one -- a signal added two commits ago at review request. Clearing the property
gets the same protection and keeps the handle.
And name the file: exception in the comment above the blob lookup, which
claimed isExternalUrl() was true for everything carrying a scheme.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 66 +++++++++++++++++--
1 file changed, 59 insertions(+), 7 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 6a9bb3ef1a6..92b933294e5 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6769,6 +6769,19 @@ private static String toJavaScriptStringLiteral(String value) {
return sb.toString();
}
+ /**
+ * Severs {@code window.opener} on a window we just opened, so the page in
+ * it cannot navigate the app's own page (reverse tabnabbing).
+ *
+ *
Deliberately not the {@code noopener} window feature: that makes
+ * {@code window.open()} return null in every browser, which would destroy
+ * the only signal available for telling a blocked popup from a successful
+ * one. Clearing the property gets the same protection while keeping the
+ * handle. Wrapped, since the assignment can throw once the new window has
+ * navigated cross-origin.
+ */
+ private static final String SEVER_OPENER = "try { cn1w.opener = null; } catch (e) {}";
+
/**
* Hands a URL to the page's main thread for opening. {@code window} and
* {@code window.open} do not exist inside the Web Worker the app runs in, so
@@ -6807,13 +6820,14 @@ private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fall
script = guard
+ "var cn1a = window.navigator && window.navigator.userActivation;"
+ "var cn1w = (!cn1a || cn1a.isActive) ? window.open(" + literal + ", '_blank') : null;"
- + "if (!cn1w) { window.location.href = " + literal + "; }";
+ + "if (!cn1w) { window.location.href = " + literal + "; } else { " + SEVER_OPENER + " }";
} else {
// Report a blocked popup instead of returning as if it opened. The
// page-side null is the only evidence available -- there is no
// callback -- so turn it into a throw the host call propagates.
script = guard + "var cn1w = window.open(" + literal + ", '_blank');"
- + "if (!cn1w) { throw new Error('cn1: popup blocked'); }";
+ + "if (!cn1w) { throw new Error('cn1: popup blocked'); }"
+ + SEVER_OPENER;
}
try {
eval_(script);
@@ -7027,6 +7041,40 @@ private boolean isGestureBackedHookAvailable() {
*/
private int popupReservedForGeneration = -1;
+ /**
+ * Applies the cleanup a browser performs before it parses a URL: strip
+ * leading and trailing C0 controls and spaces, then remove every tab and
+ * newline wherever they appear.
+ *
+ *
Applied once at the top of {@link #execute(String)} so classification,
+ * the confirmation Sheet's display and the URL actually handed to the page
+ * all see the same string. Parsing the raw input separately in each place is
+ * what let " https:host@evil.example/" be classified and displayed off a
+ * scheme of {@code " https"} while the browser navigated to
+ * {@code evil.example}.
+ */
+ static String normalizeUrlForParsing(String url) {
+ if (url == null) {
+ return null;
+ }
+ int start = 0;
+ int end = url.length();
+ while (start < end && url.charAt(start) <= ' ') {
+ start++;
+ }
+ while (end > start && url.charAt(end - 1) <= ' ') {
+ end--;
+ }
+ StringBuilder sb = new StringBuilder(end - start);
+ for (int i = start; i < end; i++) {
+ char c = url.charAt(i);
+ if (c != '\t' && c != '\n' && c != '\r') {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
/**
* True when {@code url} carries exactly {@code scheme}, compared without
* regard to case since URI schemes are case-insensitive. Guards the
@@ -7183,7 +7231,10 @@ public void run() {
}
@Override
- public void execute(String url) {
+ public void execute(String rawUrl) {
+ // Everything below -- classification, the Sheet's display, and the
+ // string handed to the page -- works from this one normalized form.
+ final String url = normalizeUrlForParsing(rawUrl);
if (hasScheme(url, "javascript")) {
String cmd = url.substring(url.indexOf(":")+1);
eval_(cmd);
@@ -7193,10 +7244,11 @@ public void execute(String url) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- // Only a local path can name storage content to wrap in a Blob.
- // isExternalUrl() is false for exactly those, and true for everything
- // with a scheme -- data: included, which carries its own payload and is
- // claimed by its own branch further down.
+ // Only local content can name storage to wrap in a Blob, which is
+ // what isExternalUrl() is false for: bare paths, and file: URLs, whose
+ // scheme names local content too. Everything else with a scheme is
+ // true, data: included -- that carries its own payload and is claimed
+ // by its own branch further down.
if (!isExternalUrl(url)) {
if (exists(url)) {
try {
From 3b14d315571cfeb3c5c02dbb4104534277be8c5e Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:25:06 +0300
Subject: [PATCH 18/44] JS port: normalize only what the browser parses
Two regressions from the previous commit, which normalized the whole input.
javascript: payloads were being rewritten. Stripping tabs and newlines splices
out the newline that terminates a // comment and the whitespace separating
tokens, so javascript:// comment\nalert('x') collapsed into a single comment
and never ran -- a silent change to what the caller asked to execute, on an
API that previously passed the payload through verbatim. Detect the scheme on
the normalized string, but take the payload from the raw one.
Storage lookups were being rewritten too. A key may legitimately open or close
with a space, or contain a tab, and trimming it made exists() miss the file
and route a real download to the no-content path. Look local content up under
the path exactly as given.
Normalization exists so that classification, the Sheet's display and the
string handed to the page agree on what the BROWSER will parse, so it now
applies to exactly those and nothing else.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 21 +++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 92b933294e5..38265f0e3e3 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7232,11 +7232,17 @@ public void run() {
@Override
public void execute(String rawUrl) {
- // Everything below -- classification, the Sheet's display, and the
- // string handed to the page -- works from this one normalized form.
+ // Normalization exists to make classification, the Sheet's display and
+ // the string handed to the page agree on what the BROWSER will parse.
+ // It is therefore only applied to those: a javascript: payload and a
+ // storage path are consumed verbatim below, since stripping their tabs
+ // and newlines would change a program's meaning and a key's identity.
final String url = normalizeUrlForParsing(rawUrl);
if (hasScheme(url, "javascript")) {
- String cmd = url.substring(url.indexOf(":")+1);
+ // Payload taken from the raw string. Normalizing it would splice
+ // out newlines that terminate a // comment and tabs that separate
+ // tokens, silently changing what the caller asked to run.
+ String cmd = rawUrl.substring(rawUrl.indexOf(":")+1);
eval_(cmd);
return;
}
@@ -7250,11 +7256,14 @@ public void execute(String rawUrl) {
// true, data: included -- that carries its own payload and is claimed
// by its own branch further down.
if (!isExternalUrl(url)) {
- if (exists(url)) {
+ // Looked up under the path exactly as given: a storage key may
+ // legitimately open or close with a space, and nothing here is
+ // parsed by the browser.
+ if (exists(rawUrl)) {
try {
- Blob blob = openFileAsBlob(url);
+ Blob blob = openFileAsBlob(rawUrl);
char sep = getFileSystemSeparator();
- fileName = url;
+ fileName = rawUrl;
if (fileName.indexOf(sep) >=0) {
fileName = fileName.substring(fileName.lastIndexOf(sep)+1);
}
From bd68b0bc92fc7aee69867cfd89084c35b9123719 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:31:35 +0300
Subject: [PATCH 19/44] JS port: give existing storage entries precedence over
scheme classification
A stored key can be scheme-shaped. report:2026.pdf is a file, but once
isExternalUrl() started classifying by RFC 3986 grammar it read as a custom
URI, so the storage lookup was skipped entirely and a download turned into a
navigation or a protocol-handler invocation. The previous implementation
checked storage for anything that was not http/mailto/data, so these keys used
to work.
Gate the lookup on isBrowserOnlyScheme() instead: only the unambiguously
browser-bound schemes (special, mailto, tel, sms, data, blob, about,
javascript, protocol-relative) skip it, so an ordinary link still does not pay
for a storage round trip. Custom deep links are unaffected -- imdb:///find is
simply not in storage, the lookup misses, and classification proceeds.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 74 +++++++++++++------
1 file changed, 50 insertions(+), 24 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 38265f0e3e3..d1d2566536b 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7088,6 +7088,36 @@ private static boolean hasScheme(String url, String scheme) {
&& url.regionMatches(true, 0, scheme, 0, scheme.length());
}
+ /**
+ * True for schemes that unambiguously address the browser, so offering the
+ * string to local storage first would be pointless.
+ *
+ *
Everything else is offered to storage first, because a stored key can
+ * be scheme-shaped: {@code report:2026.pdf} is a file, not a URI, and was
+ * downloadable before {@link #isExternalUrl(String)} began classifying by
+ * grammar. Custom deep links pass through unharmed -- {@code imdb:///find}
+ * simply is not in storage, so the lookup misses and classification
+ * proceeds.
+ */
+ private static boolean isBrowserOnlyScheme(String url) {
+ if (url.startsWith("//")) {
+ return true;
+ }
+ int colon = url.indexOf(':');
+ if (colon < 1) {
+ return false;
+ }
+ String scheme = url.substring(0, colon);
+ return isSpecialScheme(scheme)
+ || "mailto".equalsIgnoreCase(scheme)
+ || "tel".equalsIgnoreCase(scheme)
+ || "sms".equalsIgnoreCase(scheme)
+ || "data".equalsIgnoreCase(scheme)
+ || "blob".equalsIgnoreCase(scheme)
+ || "about".equalsIgnoreCase(scheme)
+ || "javascript".equalsIgnoreCase(scheme);
+ }
+
/**
* True when the URL names something the browser itself can hand off, as
* opposed to a path into local storage that {@link #execute(String)} is
@@ -7250,30 +7280,26 @@ public void execute(String rawUrl) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- // Only local content can name storage to wrap in a Blob, which is
- // what isExternalUrl() is false for: bare paths, and file: URLs, whose
- // scheme names local content too. Everything else with a scheme is
- // true, data: included -- that carries its own payload and is claimed
- // by its own branch further down.
- if (!isExternalUrl(url)) {
- // Looked up under the path exactly as given: a storage key may
- // legitimately open or close with a space, and nothing here is
- // parsed by the browser.
- if (exists(rawUrl)) {
- try {
- Blob blob = openFileAsBlob(rawUrl);
- char sep = getFileSystemSeparator();
- fileName = rawUrl;
- if (fileName.indexOf(sep) >=0) {
- fileName = fileName.substring(fileName.lastIndexOf(sep)+1);
- }
- registerSaveBlobHandler(fileName, blob);
- useBlobHandler = true;
-
- } catch (Throwable ex) {
- // openFileAsBlob failed -- fall through to the no-blob-handler
- // branch below which opens the URL in a new window instead.
- }
+ // Storage wins for anything that could name a key. Classifying by
+ // scheme grammar alone would skip the lookup for a stored file called
+ // report:2026.pdf, which is scheme-shaped but is a file; only the
+ // unambiguously browser-bound schemes skip it, so an ordinary link does
+ // not pay for a storage round trip. Looked up under the path exactly as
+ // given, since a key may legitimately open or close with a space and
+ // nothing here is parsed by the browser.
+ if (!isBrowserOnlyScheme(url) && exists(rawUrl)) {
+ try {
+ Blob blob = openFileAsBlob(rawUrl);
+ char sep = getFileSystemSeparator();
+ fileName = rawUrl;
+ if (fileName.indexOf(sep) >= 0) {
+ fileName = fileName.substring(fileName.lastIndexOf(sep) + 1);
+ }
+ registerSaveBlobHandler(fileName, blob);
+ useBlobHandler = true;
+ } catch (Throwable ex) {
+ // openFileAsBlob failed -- fall through to the no-blob-handler
+ // branch below which opens the URL in a new window instead.
}
}
From 4e5435ada6bd16dda7494d07c4fae4c8df4df815 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:36:14 +0300
Subject: [PATCH 20/44] JS port: make execute(null) a logged no-op rather than
an NPE
Not introduced here -- master's first statement is url.startsWith(), which
throws the same way -- but the guard costs nothing and there is nothing
sensible to open, so log and return instead of crashing the caller.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../java/com/codename1/impl/html5/HTML5Implementation.java | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index d1d2566536b..93fa71c7344 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7267,6 +7267,13 @@ public void execute(String rawUrl) {
// It is therefore only applied to those: a javascript: payload and a
// storage path are consumed verbatim below, since stripping their tabs
// and newlines would change a program's meaning and a key's identity.
+ if (rawUrl == null) {
+ // Pre-existing behavior was an NPE off the first startsWith. There
+ // is nothing to open, so say so and return rather than crash the
+ // caller's EDT.
+ _log("execute(): ignoring null URL");
+ return;
+ }
final String url = normalizeUrlForParsing(rawUrl);
if (hasScheme(url, "javascript")) {
// Payload taken from the raw string. Normalizing it would splice
From 0abe698c1d7bf4c6b52764c259e4017cf44e4dae Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:38:08 +0300
Subject: [PATCH 21/44] JS port: keep activation-dependent hooks off the
polling drain
The backsideHooksInterval timer drains the same global hook queue, carrying no
transient activation. Fixing only the gate that decides whether to queue left
the hooks themselves on that queue, so a popup could lose the race to the
gesture's own timeout, be drained by the poll instead, and be blocked -- for
the confirmed _blank open that meant the link the user had just approved
silently never opened.
Add a second queue that only the gesture-backed drain in
runBacksideHooksInTimeout() runs, and put every activation-dependent hook on
it: the reserved popup, the confirmed popup, and the download path's
no-blob-handler leg in both its unconfirmed and confirmed forms. Firing a
registered blob handler stays on the shared queue, since a download is gated
on engagement rather than activation and interval polling is exactly what that
hint is for.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 61 ++++++++++++++++---
1 file changed, 53 insertions(+), 8 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 93fa71c7344..0c6da4470e2 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -632,6 +632,30 @@ public static interface JSRunnable extends JSObject {
public void addBacksideHook(JSRunnable r) {
backSideHooks.push(r);
}
+
+ /**
+ * Hooks that only a gesture-backed drain may run.
+ *
+ *
{@link #backSideHooks} is also drained by the
+ * {@code platformHint.javascript.backsideHooksInterval} timer, which
+ * carries no transient activation. That is fine for media, but a
+ * {@code window.open()} drained from the polling timer is simply blocked --
+ * so a popup queued there can lose the race to the gesture's own timeout
+ * and never open. Anything needing activation goes here instead, where only
+ * {@link #runBacksideHooksInTimeout(int)} reaches it.
+ */
+ private JSArray gestureOnlyHooks = JSArray.create();
+
+ private void addGestureOnlyHook(JSRunnable r) {
+ gestureOnlyHooks.push(r);
+ }
+
+ private void runGestureOnlyHooks() {
+ while (gestureOnlyHooks.getLength() > 0) {
+ JSRunnable r = (JSRunnable)gestureOnlyHooks.shift();
+ r.run();
+ }
+ }
// Count the number of backside hook calls that are queued up
private int backsideHooksSemaphore = 0;
@@ -663,6 +687,9 @@ private void runBacksideHooksInTimeout(int timeout) {
public void onTimer() {
backsideHooksSemaphore--;
//_log("Decrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
+ // This drain descends from a real interaction, so it is the
+ // only one allowed to run activation-dependent hooks.
+ runGestureOnlyHooks();
runBacksideHooks();
}
}, timeout);
@@ -7205,7 +7232,7 @@ private void openInNewWindowWithConfirmation(final String url, final String prom
// field. Never cleared; the next interaction simply carries a
// different generation.
popupReservedForGeneration = gestureGeneration;
- addBacksideHook(new JSRunnable() {
+ addGestureOnlyHook(new JSRunnable() {
@Override
public void run() {
// openUrlOnMainThread reports the reason on failure --
@@ -7240,9 +7267,11 @@ public void run() {
// very thing the Sheet exists to recover from. That tap did
// install a backside hook though (pointer handling calls
// installBacksideHooksInUserInteraction), so queue the open on
- // it and let the drain perform it inside the gesture. Same
- // shape as the download confirmation in execute().
- addBacksideHook(new JSRunnable() {
+ // it and let the drain perform it inside the gesture. On the
+ // gesture-only queue, or the polling interval could drain it
+ // first with no activation and block the link the user just
+ // confirmed.
+ addGestureOnlyHook(new JSRunnable() {
@Override
public void run() {
if (!openUrlOnMainThread(url, false, false)) {
@@ -7368,24 +7397,40 @@ public void run() {
? isBacksideHookAvailable()
: isGestureBackedHookAvailable();
if (hookAvailable) {
- addBacksideHook(new JSRunnable() {
+ JSRunnable hook = new JSRunnable() {
@Override
public void run() {
startDownload.run();
}
- });
+ };
+ if (fuseBlobHandler) {
+ addBacksideHook(hook);
+ } else {
+ // The no-blob leg opens a popup, so it needs the activation
+ // only a gesture-backed drain carries -- same reason its gate
+ // above is the stricter one.
+ addGestureOnlyHook(hook);
+ }
} else {
String dlName = fileName == null ? "file" : fileName;
createConfirmationSheet("Download file", "Download " + dlName + " now?", null, new Runnable() {
@Override
public void run() {
- addBacksideHook(new JSRunnable() {
+ JSRunnable confirmed = new JSRunnable() {
@Override
public void run() {
startDownload.run();
}
- });
+ };
+ // Same split as the unconfirmed path above: the popup leg
+ // needs the activation the OK tap just granted, which the
+ // polling interval would not carry.
+ if (fuseBlobHandler) {
+ addBacksideHook(confirmed);
+ } else {
+ addGestureOnlyHook(confirmed);
+ }
}
}).show();
}
From 5f4868e913beee00d10937887b52825e36a2c871 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:45:25 +0300
Subject: [PATCH 22/44] JS port: decide storage precedence by URL shape, not by
scheme name
The scheme allowlist was wrong twice. It regressed report:2026.pdf, and the
fix for that still regressed https:report.pdf and HTTP:report.pdf, which
master reached the storage lookup because it excluded only lowercase "http:",
"mailto:" and "data:".
Ambiguity is a property of the shape, not of the scheme name: a slashless
scheme:name is exactly as plausible a storage key as a URL. So skip the lookup
only for what cannot be a key -- a leading "//", or a SPECIAL scheme followed
by "//". That covers ordinary web links, which are the volume and are never
keys, so the common path still pays nothing for the up-to-two IndexedDB reads
exists() costs.
Everything else consults storage first, which restores file:///x and custom
myapp://x to the lookup they had before this branch as well. A miss falls
through to classification, so deep links behave as they always did.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 61 ++++++++++---------
1 file changed, 33 insertions(+), 28 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 0c6da4470e2..492e382f70a 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7116,33 +7116,38 @@ private static boolean hasScheme(String url, String scheme) {
}
/**
- * True for schemes that unambiguously address the browser, so offering the
- * string to local storage first would be pointless.
+ * True when the string cannot plausibly be a storage key, so the storage
+ * lookup in {@link #execute(String)} can be skipped.
*
- *
Everything else is offered to storage first, because a stored key can
- * be scheme-shaped: {@code report:2026.pdf} is a file, not a URI, and was
- * downloadable before {@link #isExternalUrl(String)} began classifying by
- * grammar. Custom deep links pass through unharmed -- {@code imdb:///find}
- * simply is not in storage, so the lookup misses and classification
- * proceeds.
+ *
That means it opens with {@code //}, or with a SPECIAL scheme followed
+ * by {@code //}. Nobody names a stored file {@code https://example.com/x},
+ * and those are the overwhelming majority of links, so the common path pays
+ * nothing. Every other shape is offered to storage first -- slashless forms
+ * like {@code https:report.pdf} and {@code report:2026.pdf}, which a browser
+ * would happily parse as URLs but which a caller may just as legitimately
+ * have stored under that name, and also {@code file:///x} and custom
+ * {@code myapp://x}, which named local content or could be a key. Storage
+ * only wins if the key actually exists; a miss falls through to
+ * classification, so deep links are unaffected.
+ *
+ *
Deciding this by scheme allowlist instead was wrong twice over: it
+ * regressed {@code report:2026.pdf}, then still regressed
+ * {@code https:report.pdf}. Ambiguity is a property of the shape, not of
+ * the scheme name.
*/
- private static boolean isBrowserOnlyScheme(String url) {
+ private static boolean isUnambiguousBrowserUrl(String url) {
if (url.startsWith("//")) {
return true;
}
- int colon = url.indexOf(':');
- if (colon < 1) {
+ int sep = url.indexOf("://");
+ if (sep < 1) {
return false;
}
- String scheme = url.substring(0, colon);
- return isSpecialScheme(scheme)
- || "mailto".equalsIgnoreCase(scheme)
- || "tel".equalsIgnoreCase(scheme)
- || "sms".equalsIgnoreCase(scheme)
- || "data".equalsIgnoreCase(scheme)
- || "blob".equalsIgnoreCase(scheme)
- || "about".equalsIgnoreCase(scheme)
- || "javascript".equalsIgnoreCase(scheme);
+ // Special schemes only. file:///x names local content, and a custom
+ // myapp://x could be a key as easily as a deep link; both were reaching
+ // the lookup before this branch and still should. What this skips is
+ // ordinary web links, which are the volume and are never keys.
+ return isSpecialScheme(url.substring(0, sep));
}
/**
@@ -7316,14 +7321,14 @@ public void execute(String rawUrl) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- // Storage wins for anything that could name a key. Classifying by
- // scheme grammar alone would skip the lookup for a stored file called
- // report:2026.pdf, which is scheme-shaped but is a file; only the
- // unambiguously browser-bound schemes skip it, so an ordinary link does
- // not pay for a storage round trip. Looked up under the path exactly as
- // given, since a key may legitimately open or close with a space and
- // nothing here is parsed by the browser.
- if (!isBrowserOnlyScheme(url) && exists(rawUrl)) {
+ // Storage wins for anything that could name a key -- a stored file may
+ // be called report:2026.pdf or https:report.pdf, both of which a
+ // browser would read as URLs. Only shapes that cannot be a key skip the
+ // lookup, since exists() costs up to two IndexedDB reads and ordinary
+ // https://... links should not pay for them. Looked up under the path
+ // exactly as given: a key may legitimately open or close with a space,
+ // and nothing here is parsed by the browser.
+ if (!isUnambiguousBrowserUrl(url) && exists(rawUrl)) {
try {
Blob blob = openFileAsBlob(rawUrl);
char sep = getFileSystemSeparator();
From d7a25a5b7c969b5152fbc5a970e48c6ca54e9393 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:50:42 +0300
Subject: [PATCH 23/44] JS port: run each gesture-only hook from its own
gesture's drain
A single global queue was not enough. Drains from an earlier interaction stay
pending for up to five seconds, so when the user clicks OK while one is
outstanding, that stale drain can consume the confirmed popup -- carrying an
activation that is spent or expiring, so window.open() is blocked and the link
the user just approved never opens.
Tag each hook with the gestureGeneration that queued it, have every drain
remember the generation that scheduled it, and run only the hooks that match.
A hook now runs inside the activation of the interaction that asked for it, or
not at all.
Collecting matches before running them keeps a hook that queues another hook
from corrupting the iteration, and entries more than eight generations old are
dropped on insert as a backstop -- those can only exist if the EDT took longer
than the whole drain burst to reach the queue, and nothing would ever drain
them.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 57 ++++++++++++++++---
1 file changed, 48 insertions(+), 9 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 492e382f70a..70e6027d7bf 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -644,16 +644,50 @@ public void addBacksideHook(JSRunnable r) {
* and never open. Anything needing activation goes here instead, where only
* {@link #runBacksideHooksInTimeout(int)} reaches it.
*/
- private JSArray gestureOnlyHooks = JSArray.create();
+ private final java.util.ArrayList gestureOnlyHooks =
+ new java.util.ArrayList();
+ /** A hook paired with the interaction that queued it. */
+ private static class GestureHook {
+ final int generation;
+ final JSRunnable runnable;
+
+ GestureHook(int generation, JSRunnable runnable) {
+ this.generation = generation;
+ this.runnable = runnable;
+ }
+ }
+
+ /**
+ * Queues a hook that only a drain scheduled by the CURRENT interaction may
+ * run. A drain descending from an earlier interaction carries that one's
+ * activation, which is spent or expiring, so letting it consume this hook
+ * would leave the popup blocked.
+ */
private void addGestureOnlyHook(JSRunnable r) {
- gestureOnlyHooks.push(r);
+ // Backstop against unbounded growth: a hook whose interaction is long
+ // past can no longer be drained, which only happens if the EDT took
+ // longer than the whole 5s drain burst to reach us.
+ for (int i = gestureOnlyHooks.size() - 1; i >= 0; i--) {
+ if (gestureGeneration - gestureOnlyHooks.get(i).generation > 8) {
+ gestureOnlyHooks.remove(i);
+ }
+ }
+ gestureOnlyHooks.add(new GestureHook(gestureGeneration, r));
}
- private void runGestureOnlyHooks() {
- while (gestureOnlyHooks.getLength() > 0) {
- JSRunnable r = (JSRunnable)gestureOnlyHooks.shift();
- r.run();
+ private void runGestureOnlyHooks(int generation) {
+ // Collected before running: a hook may queue another one, and mutating
+ // the list mid-iteration would break it.
+ java.util.ArrayList due = new java.util.ArrayList();
+ for (int i = 0; i < gestureOnlyHooks.size(); i++) {
+ if (gestureOnlyHooks.get(i).generation == generation) {
+ due.add(gestureOnlyHooks.get(i));
+ }
+ }
+ gestureOnlyHooks.removeAll(due);
+ for (int i = 0; i < due.size(); i++) {
+ due.get(i).runnable.run();
}
}
@@ -682,14 +716,19 @@ public boolean isBacksideHookAvailable() {
private void runBacksideHooksInTimeout(int timeout) {
backsideHooksSemaphore++;
//_log("Incrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
+ // Remember which interaction scheduled this drain, so it only runs the
+ // activation-dependent hooks that same interaction queued.
+ final int generation = gestureGeneration;
Window.setTimeout(new TimerHandler() {
@Override
public void onTimer() {
backsideHooksSemaphore--;
//_log("Decrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
- // This drain descends from a real interaction, so it is the
- // only one allowed to run activation-dependent hooks.
- runGestureOnlyHooks();
+ // This drain descends from a real interaction, so it may run
+ // that interaction's activation-dependent hooks -- and only
+ // those: an older drain still pending carries an activation
+ // that is spent or expiring.
+ runGestureOnlyHooks(generation);
runBacksideHooks();
}
}, timeout);
From 5ee147eb8c371694da11538a0f228325ba9cf7d3 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 08:56:44 +0300
Subject: [PATCH 24/44] JS port: guarantee a matching drain exists for a
gesture-only hook
Generation matching stopped an older drain from stealing a hook, but it did
not establish that a drain for the CURRENT interaction is still coming. Safari
schedules exactly one (75 or 300ms), so an EDT slower than that reached
addGestureOnlyHook after the only matching drain had run: the hook sat queued
forever and the link the user confirmed did nothing at all.
Track the drains outstanding for the current generation, reset when a new
interaction begins, and decrement only from a drain that belongs to it -- an
older drain must not consume the current interaction's count. When none
remain, queueing schedules one. By then the activation is probably gone, but
an attempt that reports failure beats silence: the caller logs it, or falls
back to the Sheet.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 25 ++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 70e6027d7bf..4cd8c73e06c 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -674,8 +674,25 @@ private void addGestureOnlyHook(JSRunnable r) {
}
}
gestureOnlyHooks.add(new GestureHook(gestureGeneration, r));
+ if (pendingGestureDrains <= 0) {
+ // Generation matching keeps an older drain from taking this hook,
+ // but only a drain of THIS interaction will run it -- and there may
+ // be none left. Safari schedules a single 75/300ms drain, so an EDT
+ // slower than that would strand the hook forever and the confirmed
+ // link would do nothing at all. Schedule one. The activation is
+ // probably gone by now, but an attempt that reports failure beats
+ // silence: the caller logs it, or falls back to the Sheet.
+ runBacksideHooksInTimeout(0);
+ }
}
+ /**
+ * Drains still scheduled for {@link #gestureGeneration}. Reset when a new
+ * interaction begins, since an older interaction's drains cannot run this
+ * one's hooks.
+ */
+ private int pendingGestureDrains;
+
private void runGestureOnlyHooks(int generation) {
// Collected before running: a hook may queue another one, and mutating
// the list mid-iteration would break it.
@@ -719,10 +736,14 @@ private void runBacksideHooksInTimeout(int timeout) {
// Remember which interaction scheduled this drain, so it only runs the
// activation-dependent hooks that same interaction queued.
final int generation = gestureGeneration;
+ pendingGestureDrains++;
Window.setTimeout(new TimerHandler() {
@Override
public void onTimer() {
backsideHooksSemaphore--;
+ if (generation == gestureGeneration) {
+ pendingGestureDrains--;
+ }
//_log("Decrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
// This drain descends from a real interaction, so it may run
// that interaction's activation-dependent hooks -- and only
@@ -807,8 +828,10 @@ private static int safariBacksideHookDelay() {
private void installBacksideHooksInUserInteraction() {
// A distinct interaction, so a popup reserved against the previous one
- // no longer applies -- see popupReservedForGeneration.
+ // no longer applies -- see popupReservedForGeneration -- and the
+ // previous one's outstanding drains cannot serve this one's hooks.
gestureGeneration++;
+ pendingGestureDrains = 0;
if (isIOS() || isSafari()) {
debugLog("Installing backside hooks with delay "+safariBacksideHookDelay());
runBacksideHooksInTimeout(safariBacksideHookDelay());
From c3222283a46815f366557d2ca209fe9dafe5049a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:21:36 +0300
Subject: [PATCH 25/44] JS port: judge the storage gate on the raw string,
offer a tab when blocked
The gate decided on the normalized string but looked up the raw one, so
" https://example.com/report " -- a legal key, since keys may carry
surrounding whitespace -- looked unambiguous and skipped the very lookup that
would have found it. Judge it on the same string the lookup uses, and skip
only when normalization also left that string untouched: a raw form that
normalization would change is not what the browser parses, so it can still be
a key in its own right, "https://x/y\n" among them.
And give the user somewhere to go when a confirmed open is blocked anyway. The
replacement drain runs from the EDT, so it cannot restore an activation that
already expired -- the previous commit's own comment conceded as much, and
stopping at a log leaves someone who explicitly pressed OK with nothing. There
is no DOM element to hang a page-side hook on, the OK button being CN1
rendered, so take the other option: offer the current tab, which needs no
activation and so cannot be blocked in turn. Not a loop -- that path is a
navigation, not another popup.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 45 +++++++++++++++----
1 file changed, 36 insertions(+), 9 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 4cd8c73e06c..ee057d8892e 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7196,6 +7196,11 @@ private static boolean hasScheme(String url, String scheme) {
* regressed {@code report:2026.pdf}, then still regressed
* {@code https:report.pdf}. Ambiguity is a property of the shape, not of
* the scheme name.
+ *
+ *
Takes the RAW string, the same one the lookup uses. Judging the
+ * normalized form would let {@code " https://example.com/report "} -- a
+ * perfectly legal key, since keys may carry surrounding whitespace -- look
+ * unambiguous and skip the lookup that would have found it.
*/
private static boolean isUnambiguousBrowserUrl(String url) {
if (url.startsWith("//")) {
@@ -7342,13 +7347,29 @@ public void run() {
@Override
public void run() {
if (!openUrlOnMainThread(url, false, false)) {
- // The user confirmed and it still did not open.
- // openUrlOnMainThread has already logged why; note
- // only that this was the confirmed attempt, which
- // is the end of the line -- re-showing the Sheet
- // from here would just loop.
+ // The user confirmed and it still did not open --
+ // a browser setting, an extension, or an activation
+ // that expired before this drain. Do not stop at a
+ // log: they acted and deserve a way through. Offer
+ // the current tab, which needs no activation and so
+ // cannot be blocked in turn. Not a loop -- that
+ // path is a navigation, not another popup.
_log("execute(): confirmed open did not succeed for "
+ url);
+ callSerially(new Runnable() {
+ @Override
+ public void run() {
+ createConfirmationSheet("Open Link",
+ "Your browser blocked the new window."
+ + " Open this link in the current tab instead?",
+ shortenUrlForDisplay(url), new Runnable() {
+ @Override
+ public void run() {
+ openUrlOnMainThread(url, true, false);
+ }
+ }).show();
+ }
+ });
}
}
});
@@ -7387,10 +7408,16 @@ public void execute(String rawUrl) {
// be called report:2026.pdf or https:report.pdf, both of which a
// browser would read as URLs. Only shapes that cannot be a key skip the
// lookup, since exists() costs up to two IndexedDB reads and ordinary
- // https://... links should not pay for them. Looked up under the path
- // exactly as given: a key may legitimately open or close with a space,
- // and nothing here is parsed by the browser.
- if (!isUnambiguousBrowserUrl(url) && exists(rawUrl)) {
+ // https://... links should not pay for them. Both the decision and the
+ // lookup read rawUrl: a key may legitimately open or close with a
+ // space, so judging the normalized form would skip the lookup for keys
+ // the lookup would have found.
+ // ...and only when normalization left the string untouched. If it
+ // changed anything, the raw form is not what the browser would parse,
+ // so it can still be a key in its own right -- "https://x/y\n" among
+ // them, which the shape test alone would wave through.
+ boolean cannotBeStorageKey = isUnambiguousBrowserUrl(rawUrl) && rawUrl.equals(url);
+ if (!cannotBeStorageKey && exists(rawUrl)) {
try {
Blob blob = openFileAsBlob(rawUrl);
char sep = getFileSystemSeparator();
From 4dd6d4545940681ef0b17e8d981e6bd7078d5564 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:27:07 +0300
Subject: [PATCH 26/44] JS port: fire the registered handler for data: URL
downloads
The data: branch registers a save handler exactly as the Blob branch does, but
useBlobHandler stayed false and was captured into fuseBlobHandler before the
branch ran, so the download path took the other leg: the Sheet's OK opened the
data: URL in a new tab instead of downloading it, a gesture-backed call could
produce a stray tab alongside the registration, and where the browser blocks
top-level data: navigation the user got nothing at all despite a handler
sitting registered.
Mark the handler as registered and capture the flag after the chain rather
than before it. That also routes the case correctly downstream, where the
gate and the hook queue both key off the same flag: a download is gated on
engagement, so it belongs on the shared queue, not the gesture-only one.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/html5/HTML5Implementation.java | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index ee057d8892e..abe5a39800d 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7433,8 +7433,6 @@ public void execute(String rawUrl) {
}
}
- final boolean fuseBlobHandler = useBlobHandler;
-
String buttonText = null;
//String icon = null;
if (useBlobHandler) {
@@ -7444,6 +7442,12 @@ public void execute(String rawUrl) {
nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
} else if (hasScheme(url, "data")) {
registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, url);
+ // A handler is registered now, exactly as in the Blob case, so the
+ // download path below must FIRE it. Leaving this false sent the
+ // confirmed OK to window.open() on the data: URL instead -- a tab
+ // rather than the download the Sheet advertised, and nothing at all
+ // where the browser blocks top-level data: navigation.
+ useBlobHandler = true;
//popover.setContents("");
buttonText = "Click to Download "+(fileName!=null?fileName:"File");
nativeButton.setText(buttonText);
@@ -7470,6 +7474,9 @@ public void execute(String rawUrl) {
openInNewWindowWithConfirmation(url, "Open this link?");
return;
}
+ // Captured after the chain: the data: branch registers a handler too,
+ // and capturing before it read false for that case.
+ final boolean fuseBlobHandler = useBlobHandler;
final Runnable startDownload = new Runnable() {
@Override
public void run() {
From 8ac5e7bf4dbfa9b14ff17af6e596ce4c60b5d899 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:28:30 +0300
Subject: [PATCH 27/44] JS port: keep the data: payload raw, rename the
download flag
registerSaveBlobHandlerDataUrl() was handed the normalized URL, so
normalization's tab and newline stripping applied to the download's own
payload rather than to something merely being classified. Pass the raw string,
same rule as the javascript: payload and the storage key: normalize what the
browser parses, never what it carries.
Rename fuseBlobHandler to handlerRegistered and say plainly why the copy
exists -- useBlobHandler is assigned in the branches above, so it is not
effectively final and the anonymous classes cannot capture it. The opaque name
drew four review comments across the branch, and its capture position was a
real defect one commit ago, so it is worth spelling out rather than defending.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 24 ++++++++++++-------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index abe5a39800d..8c65b5b79c0 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7441,7 +7441,12 @@ public void execute(String rawUrl) {
nativeButton.setText(buttonText);
nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
} else if (hasScheme(url, "data")) {
- registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, url);
+ // The RAW URL, not the normalized one: normalization strips tabs
+ // and newlines anywhere in the string, and this value is the
+ // download's payload, not something we are only classifying. Same
+ // rule as the javascript: payload and the storage key -- normalize
+ // what the browser parses, never what it carries.
+ registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, rawUrl);
// A handler is registered now, exactly as in the Blob case, so the
// download path below must FIRE it. Leaving this false sent the
// confirmed OK to window.open() on the data: URL instead -- a tab
@@ -7474,13 +7479,16 @@ public void execute(String rawUrl) {
openInNewWindowWithConfirmation(url, "Open this link?");
return;
}
- // Captured after the chain: the data: branch registers a handler too,
- // and capturing before it read false for that case.
- final boolean fuseBlobHandler = useBlobHandler;
+ // A final copy of useBlobHandler, needed because the anonymous classes
+ // below cannot capture it -- it is assigned in the branches above and
+ // so is not effectively final. Captured AFTER the chain: the data:
+ // branch registers a handler too, and capturing beforehand read false
+ // for that case and sent the download down the popup leg.
+ final boolean handlerRegistered = useBlobHandler;
final Runnable startDownload = new Runnable() {
@Override
public void run() {
- if (fuseBlobHandler) {
+ if (handlerRegistered) {
fireSaveBlobHandler();
} else {
_log("Opening URL in new window");
@@ -7494,7 +7502,7 @@ public void run() {
// instead, and that does need a real gesture -- draining it from a timer
// would let the browser block it with no Sheet shown. Pick the gate that
// matches the leg this run will actually take.
- boolean hookAvailable = fuseBlobHandler
+ boolean hookAvailable = handlerRegistered
? isBacksideHookAvailable()
: isGestureBackedHookAvailable();
if (hookAvailable) {
@@ -7504,7 +7512,7 @@ public void run() {
startDownload.run();
}
};
- if (fuseBlobHandler) {
+ if (handlerRegistered) {
addBacksideHook(hook);
} else {
// The no-blob leg opens a popup, so it needs the activation
@@ -7527,7 +7535,7 @@ public void run() {
// Same split as the unconfirmed path above: the popup leg
// needs the activation the OK tap just granted, which the
// polling interval would not carry.
- if (fuseBlobHandler) {
+ if (handlerRegistered) {
addBacksideHook(confirmed);
} else {
addGestureOnlyHook(confirmed);
From fcb11309023946281b94981b4af63f106604ff4f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:37:18 +0300
Subject: [PATCH 28/44] JS port: never let a stale queued open spend a newer
gesture's activation
Transient activation belongs to the window, not to the generation I record
against a hook. So when interaction B arrives before interaction A's delayed
drain, running A's popup on that drain consumed the activation B had just
granted, and B's own popup was then blocked -- having done nothing wrong, and
needing a confirmation it should not have needed.
A drain now runs its hooks only while its generation is still the current one.
Superseded hooks are handed a fallback instead, which asks. Deliberately asks
rather than re-queues: queueing again would open A's URL off a gesture the
user made for something else, which is the same theft from the other side.
Splitting showOpenConfirmation() out of openInNewWindowWithConfirmation() is
what makes that fallback safe -- it only ever shows the Sheet, never queues a
popup of its own, so it cannot re-enter the path that just failed. The blocked
popup fallback moves out to showBlockedPopupFallback() for the same reason.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 101 ++++++++++++++----
1 file changed, 80 insertions(+), 21 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 8c65b5b79c0..37f36dc6061 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -651,10 +651,13 @@ public void addBacksideHook(JSRunnable r) {
private static class GestureHook {
final int generation;
final JSRunnable runnable;
+ /** Run on the EDT instead, if a newer interaction supersedes this one. */
+ final Runnable superseded;
- GestureHook(int generation, JSRunnable runnable) {
+ GestureHook(int generation, JSRunnable runnable, Runnable superseded) {
this.generation = generation;
this.runnable = runnable;
+ this.superseded = superseded;
}
}
@@ -664,7 +667,7 @@ private static class GestureHook {
* activation, which is spent or expiring, so letting it consume this hook
* would leave the popup blocked.
*/
- private void addGestureOnlyHook(JSRunnable r) {
+ private void addGestureOnlyHook(JSRunnable r, Runnable superseded) {
// Backstop against unbounded growth: a hook whose interaction is long
// past can no longer be drained, which only happens if the EDT took
// longer than the whole 5s drain burst to reach us.
@@ -673,7 +676,7 @@ private void addGestureOnlyHook(JSRunnable r) {
gestureOnlyHooks.remove(i);
}
}
- gestureOnlyHooks.add(new GestureHook(gestureGeneration, r));
+ gestureOnlyHooks.add(new GestureHook(gestureGeneration, r, superseded));
if (pendingGestureDrains <= 0) {
// Generation matching keeps an older drain from taking this hook,
// but only a drain of THIS interaction will run it -- and there may
@@ -703,8 +706,22 @@ private void runGestureOnlyHooks(int generation) {
}
}
gestureOnlyHooks.removeAll(due);
+ // Transient activation belongs to the WINDOW, not to this generation.
+ // If a newer interaction has since occurred, running these would spend
+ // the activation it just granted -- the newer interaction's own popup
+ // would then be blocked, having done nothing wrong. Hand them to their
+ // fallback instead.
+ boolean superseded = generation != gestureGeneration;
for (int i = 0; i < due.size(); i++) {
- due.get(i).runnable.run();
+ GestureHook hook = due.get(i);
+ if (superseded) {
+ _log("execute(): a newer interaction superseded a queued open");
+ if (hook.superseded != null) {
+ callSerially(hook.superseded);
+ }
+ } else {
+ hook.runnable.run();
+ }
}
}
@@ -7314,21 +7331,37 @@ public void run() {
// A browser setting, an extension or an activation that
// expired before the drain can still block the one
// reserved attempt. Fall back to asking rather than
- // leaving the caller with a dead click. Re-entering is
- // safe and cannot loop: this generation's reservation is
- // already spent, so it lands on the Sheet. Hop to the
- // EDT first -- this runs on a hook drain.
+ // leaving the caller with a dead click. Hop to the EDT
+ // first -- this runs on a hook drain.
callSerially(new Runnable() {
@Override
public void run() {
- openInNewWindowWithConfirmation(url, prompt);
+ showOpenConfirmation(url, prompt);
}
});
}
}
+ }, new Runnable() {
+ @Override
+ public void run() {
+ // Superseded by a newer interaction. Ask rather than
+ // re-queue: queueing again would open this URL off a
+ // gesture the user made for something else.
+ showOpenConfirmation(url, prompt);
+ }
});
return;
}
+ showOpenConfirmation(url, prompt);
+ }
+
+ /**
+ * Asks the user whether to open {@code url}, and on OK queues the open
+ * against the tap that answered. Always shows the Sheet -- never queues a
+ * popup on its own -- so it is safe as the fallback for a hook that could
+ * not run.
+ */
+ private void showOpenConfirmation(final String url, final String prompt) {
createConfirmationSheet("Open Link", prompt,
shortenUrlForDisplay(url), new Runnable() {
@Override
@@ -7359,24 +7392,39 @@ public void run() {
callSerially(new Runnable() {
@Override
public void run() {
- createConfirmationSheet("Open Link",
- "Your browser blocked the new window."
- + " Open this link in the current tab instead?",
- shortenUrlForDisplay(url), new Runnable() {
- @Override
- public void run() {
- openUrlOnMainThread(url, true, false);
- }
- }).show();
+ showBlockedPopupFallback(url);
}
});
}
}
+ }, new Runnable() {
+ @Override
+ public void run() {
+ // The confirming tap was superseded before its drain
+ // ran. Ask again rather than spend the newer gesture.
+ showOpenConfirmation(url, prompt);
+ }
});
}
}).show();
}
+ /**
+ * Last resort after a confirmed open was blocked anyway: offer the current
+ * tab, which needs no activation and so cannot be blocked in turn.
+ */
+ private void showBlockedPopupFallback(final String url) {
+ createConfirmationSheet("Open Link",
+ "Your browser blocked the new window."
+ + " Open this link in the current tab instead?",
+ shortenUrlForDisplay(url), new Runnable() {
+ @Override
+ public void run() {
+ openUrlOnMainThread(url, true, false);
+ }
+ }).show();
+ }
+
@Override
public void execute(String rawUrl) {
// Normalization exists to make classification, the Sheet's display and
@@ -7517,8 +7565,14 @@ public void run() {
} else {
// The no-blob leg opens a popup, so it needs the activation
// only a gesture-backed drain carries -- same reason its gate
- // above is the stricter one.
- addGestureOnlyHook(hook);
+ // above is the stricter one. If a newer interaction supersedes
+ // it, ask rather than spend that interaction's activation.
+ addGestureOnlyHook(hook, new Runnable() {
+ @Override
+ public void run() {
+ showOpenConfirmation(url, "Open this link?");
+ }
+ });
}
} else {
@@ -7538,7 +7592,12 @@ public void run() {
if (handlerRegistered) {
addBacksideHook(confirmed);
} else {
- addGestureOnlyHook(confirmed);
+ addGestureOnlyHook(confirmed, new Runnable() {
+ @Override
+ public void run() {
+ showOpenConfirmation(url, "Open this link?");
+ }
+ });
}
}
}).show();
From 8d0930db6ca2e541dc916453ce438cfa7921fc36 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:46:29 +0300
Subject: [PATCH 29/44] JS port: keep data: URLs out of the storage lookup
Basing the gate on shape rather than scheme made data: a lookup candidate,
since it carries no "://". exists() would then push the whole base64 payload
through the host bridge as a storage key, twice, blocking the EDT for a
multi-megabyte string that can never be one -- the branch below claims data:
in every case, and the parent implementation excluded it here too.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/html5/HTML5Implementation.java | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 37f36dc6061..841075df1fd 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7464,7 +7464,13 @@ public void execute(String rawUrl) {
// changed anything, the raw form is not what the browser would parse,
// so it can still be a key in its own right -- "https://x/y\n" among
// them, which the shape test alone would wave through.
- boolean cannotBeStorageKey = isUnambiguousBrowserUrl(rawUrl) && rawUrl.equals(url);
+ // data: carries its own payload and is claimed by its own branch
+ // below, so it is never a lookup candidate -- and must not become one:
+ // exists() would push a multi-megabyte base64 string through the host
+ // bridge as a key, twice, blocking the EDT for nothing. The parent
+ // implementation excluded data: here too.
+ boolean cannotBeStorageKey = hasScheme(url, "data")
+ || (isUnambiguousBrowserUrl(rawUrl) && rawUrl.equals(url));
if (!cannotBeStorageKey && exists(rawUrl)) {
try {
Blob blob = openFileAsBlob(rawUrl);
From 444337e418f692df749a61ebcbad196054af9412 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:05:25 +0300
Subject: [PATCH 30/44] JS port: say so when no page-side channel exists,
rather than asking
A host bundle predating __cn1_eval_on_main__ leaves the worker with no channel
to the page, so every open fails -- including the ones behind the confirmation
Sheet and its open-in-this-tab fallback. The user was being asked to confirm,
then asked again, and nothing could ever happen, because asking only helps
when the obstacle is a missing gesture.
Probe the channel once, cache it, and in that case log and show a plain
message instead of a confirmation no button on it could satisfy. There is no
older API to fall back to: the port's only other page-side entry points, the
save-blob handlers, ship from the same bundle.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 58 +++++++++++++++++++
1 file changed, 58 insertions(+)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 841075df1fd..d1f9a1d87f7 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6953,7 +6953,58 @@ private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fall
/** Marker appended to a URL shortened for the confirmation Sheet. */
private static final String DISPLAY_URL_ELLIPSIS = "...";
+ /** 0 unknown, 1 reachable, -1 not reachable. */
+ private int mainThreadBridgeState;
+
+ /**
+ * Whether scripts can reach the page at all, cached after one probe.
+ *
+ *
A host bundle predating the {@code __cn1_eval_on_main__} handler leaves
+ * the worker with no page-side channel, so every open fails. Knowing that
+ * up front matters because the recovery for a blocked popup -- ask the user
+ * -- is worthless here: the Sheet would promise something no button on it
+ * can deliver.
+ */
+ private boolean isMainThreadBridgeAvailable() {
+ if (mainThreadBridgeState == 0) {
+ try {
+ // A no-op that asserts page context, nothing else.
+ eval_("if (typeof document === 'undefined') {"
+ + " throw new Error('cn1: not running on the page'); }");
+ mainThreadBridgeState = 1;
+ } catch (Throwable t) {
+ mainThreadBridgeState = -1;
+ }
+ }
+ return mainThreadBridgeState == 1;
+ }
+
+ /**
+ * Tells the user a link cannot be opened, for the one case where no
+ * confirmation could help. Informational, so a single dismiss button.
+ */
+ private void showCannotOpenMessage(String url) {
+ final Sheet sheet = new Sheet(null, "Open Link");
+ SpanLabel message = new SpanLabel(
+ "This app cannot open links. Its web runtime is out of date.");
+ Button close = new Button("Close");
+ close.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ sheet.back();
+ }
+ });
+ sheet.getContentPane().setLayout(BoxLayout.y());
+ sheet.getContentPane().add(message);
+ Label detail = new Label(shortenUrlForDisplay(url));
+ detail.setEndsWith3Points(true);
+ sheet.getContentPane().add(detail);
+ sheet.getContentPane().add(FlowLayout.encloseCenter(close));
+ sheet.show();
+ }
+
/**
+ * A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one /**
* A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one
* in the confirmation Sheet blew the content pane's preferred width past the
* screen and pushed the buttons out of reach. Shorten it for display; the
@@ -7300,6 +7351,13 @@ && openUrlOnMainThread(url, false, true)) {
// Sheet is a compatibility fallback the caller never requested, so
// promising a new window there would describe behavior they did not
// choose.
+ if (!isMainThreadBridgeAvailable()) {
+ // No page-side channel at all, so no button could make this work.
+ // Say so instead of showing a confirmation that cannot succeed.
+ _log("execute(): no page-side channel on this host bundle, cannot open " + url);
+ showCannotOpenMessage(url);
+ return;
+ }
openInNewWindowWithConfirmation(url, EXECUTE_TARGET_BLANK.equals(target)
? "Open this link in a new window?"
: "Open this link?");
From de755a4ae4ad9250452e4205887f7effbd58d7f8 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:11:18 +0300
Subject: [PATCH 31/44] JS port: recognize backslash forms of a scheme-relative
authority
Browsers treat any pair of "/" or "\" after nothing as a scheme-relative
marker when the base URL is special, which a served app's always is. So
\\host/p, \/host/p and /\host/p navigate to host exactly as //host/p does.
Only the "//" spelling was recognized, so the other three were classified as
local paths and, on the display side, fell through to prefix truncation --
\\www.paypal.com...@evil.example/path showed its user info while confirming
opened evil.example.
Rather than teach three call sites the new spelling, they now share
startsWithAuthorityMarker(). Answering "where does the authority start"
separately in classification, the storage gate and the display is what let
earlier shapes be fixed in one and missed in another; this is the eighth
shape, and each has cost a round.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 33 +++++++++++++++----
1 file changed, 27 insertions(+), 6 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index d1f9a1d87f7..f4c0cfe5329 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7020,9 +7020,9 @@ static String shortenUrlForDisplay(String url) {
// trusted looking path> reads as one over-long authority and the tail
// rule below would show the attacker's suffix instead of the host.
boolean special;
- if (url.startsWith("//")) {
- // Protocol relative. isExternalUrl() accepts these, so they reach
- // the Sheet and carry an authority that needs the same parsing --
+ if (startsWithAuthorityMarker(url)) {
+ // Scheme relative. isExternalUrl() accepts these, so they reach the
+ // Sheet and carry an authority that needs the same parsing --
// indexOf("://") does not find one. The page's own scheme applies,
// which for a served app is http(s), so treat it as special.
rest = url.substring(2);
@@ -7233,6 +7233,27 @@ static String normalizeUrlForParsing(String url) {
}
/**
+ * True when the string opens with two path separators, which makes it
+ * scheme relative: the browser reads what follows as an authority.
+ *
+ *
Any combination of {@code /} and {@code \\} counts, because a special
+ * base URL -- and an app served over http(s) always has one -- treats them
+ * alike. So {@code \\\\host/p}, {@code \\/host/p} and {@code /\\host/p} all
+ * navigate to {@code host} exactly as {@code //host/p} does.
+ *
+ *
Shared by every place that has to agree on where an authority starts:
+ * classification, the storage gate and the confirmation display. Answering
+ * this question separately in each is what let earlier authority shapes be
+ * fixed in one and missed in another.
+ */
+ private static boolean startsWithAuthorityMarker(String url) {
+ return url.length() >= 2
+ && (url.charAt(0) == '/' || url.charAt(0) == '\\')
+ && (url.charAt(1) == '/' || url.charAt(1) == '\\');
+ }
+
+ /**
+ * True when {@code url} carries exactly {@code scheme}, compared without /**
* True when {@code url} carries exactly {@code scheme}, compared without
* regard to case since URI schemes are case-insensitive. Guards the
* {@code javascript:} and {@code data:} branches of {@link #execute(String)},
@@ -7271,7 +7292,7 @@ private static boolean hasScheme(String url, String scheme) {
* unambiguous and skip the lookup that would have found it.
*/
private static boolean isUnambiguousBrowserUrl(String url) {
- if (url.startsWith("//")) {
+ if (startsWithAuthorityMarker(url)) {
return true;
}
int sep = url.indexOf("://");
@@ -7300,8 +7321,8 @@ private static boolean isUnambiguousBrowserUrl(String url) {
* scheme at all, it names local content.
*/
private static boolean isExternalUrl(String url) {
- if (url.startsWith("//")) {
- // Protocol relative -- the page's own scheme applies.
+ if (startsWithAuthorityMarker(url)) {
+ // Scheme relative -- the page's own scheme applies.
return true;
}
int colon = url.indexOf(':');
From 03b9b2d6f3c2fc4bcd17514540a72ea9201ffd1a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:17:31 +0300
Subject: [PATCH 32/44] JS port: consult storage for every shape, and stop
guessing which cannot be keys
Storage names are unrestricted, so no shape can be ruled out in advance.
report:2026.pdf, https:report.pdf and https://example.com/report are all legal
keys, and three successive attempts to characterize "cannot be a storage key"
each regressed one of them -- the scheme allowlist, then the shape rule, then
the shape rule plus normalization invariance. The parent implementation
consulted storage for every shape but http:, mailto: and data:, so skipping
https:// was a regression I introduced as an optimization nobody asked for.
Consult storage for everything and let the miss decide. data: remains the one
exclusion, and it is the only one that holds up: there the string is the
download's payload rather than a name, and exists() would push a
multi-megabyte base64 blob through the host bridge as a key, twice, for
something that can never match.
Costs up to two IndexedDB reads before opening an external link, which is what
the parent paid for every shape but http:. Net 44 lines lighter.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 76 ++++---------------
1 file changed, 16 insertions(+), 60 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index f4c0cfe5329..66152071d75 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7266,46 +7266,6 @@ private static boolean hasScheme(String url, String scheme) {
&& url.regionMatches(true, 0, scheme, 0, scheme.length());
}
- /**
- * True when the string cannot plausibly be a storage key, so the storage
- * lookup in {@link #execute(String)} can be skipped.
- *
- *
That means it opens with {@code //}, or with a SPECIAL scheme followed
- * by {@code //}. Nobody names a stored file {@code https://example.com/x},
- * and those are the overwhelming majority of links, so the common path pays
- * nothing. Every other shape is offered to storage first -- slashless forms
- * like {@code https:report.pdf} and {@code report:2026.pdf}, which a browser
- * would happily parse as URLs but which a caller may just as legitimately
- * have stored under that name, and also {@code file:///x} and custom
- * {@code myapp://x}, which named local content or could be a key. Storage
- * only wins if the key actually exists; a miss falls through to
- * classification, so deep links are unaffected.
- *
- *
Deciding this by scheme allowlist instead was wrong twice over: it
- * regressed {@code report:2026.pdf}, then still regressed
- * {@code https:report.pdf}. Ambiguity is a property of the shape, not of
- * the scheme name.
- *
- *
Takes the RAW string, the same one the lookup uses. Judging the
- * normalized form would let {@code " https://example.com/report "} -- a
- * perfectly legal key, since keys may carry surrounding whitespace -- look
- * unambiguous and skip the lookup that would have found it.
- */
- private static boolean isUnambiguousBrowserUrl(String url) {
- if (startsWithAuthorityMarker(url)) {
- return true;
- }
- int sep = url.indexOf("://");
- if (sep < 1) {
- return false;
- }
- // Special schemes only. file:///x names local content, and a custom
- // myapp://x could be a key as easily as a deep link; both were reaching
- // the lookup before this branch and still should. What this skips is
- // ordinary web links, which are the volume and are never keys.
- return isSpecialScheme(url.substring(0, sep));
- }
-
/**
* True when the URL names something the browser itself can hand off, as
* opposed to a path into local storage that {@link #execute(String)} is
@@ -7531,26 +7491,22 @@ public void execute(String rawUrl) {
String fileName = null;
boolean useBlobHandler = false;
Button nativeButton = new Button();
- // Storage wins for anything that could name a key -- a stored file may
- // be called report:2026.pdf or https:report.pdf, both of which a
- // browser would read as URLs. Only shapes that cannot be a key skip the
- // lookup, since exists() costs up to two IndexedDB reads and ordinary
- // https://... links should not pay for them. Both the decision and the
- // lookup read rawUrl: a key may legitimately open or close with a
- // space, so judging the normalized form would skip the lookup for keys
- // the lookup would have found.
- // ...and only when normalization left the string untouched. If it
- // changed anything, the raw form is not what the browser would parse,
- // so it can still be a key in its own right -- "https://x/y\n" among
- // them, which the shape test alone would wave through.
- // data: carries its own payload and is claimed by its own branch
- // below, so it is never a lookup candidate -- and must not become one:
- // exists() would push a multi-megabyte base64 string through the host
- // bridge as a key, twice, blocking the EDT for nothing. The parent
- // implementation excluded data: here too.
- boolean cannotBeStorageKey = hasScheme(url, "data")
- || (isUnambiguousBrowserUrl(rawUrl) && rawUrl.equals(url));
- if (!cannotBeStorageKey && exists(rawUrl)) {
+ // Storage names are unrestricted, so NO shape can be ruled out in
+ // advance: report:2026.pdf, https:report.pdf and https://example.com/x
+ // are all legal keys, and three successive attempts to characterize
+ // "cannot be a key" each regressed one of them. Consult storage for
+ // everything and let the miss decide. The parent implementation did
+ // the same for every shape but http:, mailto: and data:.
+ //
+ // data: stays excluded, and it is the only exclusion that holds up:
+ // there the string is the download's payload rather than a name, and
+ // exists() would push a multi-megabyte base64 blob through the host
+ // bridge as a key, twice, for something that can never match.
+ //
+ // Looked up under the path exactly as given -- a key may legitimately
+ // open or close with a space, and nothing here is parsed by the
+ // browser.
+ if (!hasScheme(url, "data") && exists(rawUrl)) {
try {
Blob blob = openFileAsBlob(rawUrl);
char sep = getFileSystemSeparator();
From 9df1023b1717cae555e1b83c2182cc00f47f6029 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:23:37 +0300
Subject: [PATCH 33/44] JS port: guard every confirmation path against a
missing page-side channel
The bridge check went into openExternalUrl() only, so the local-path fallback
and the download popup legs still prompted on a host bundle with no page-side
channel -- a confirmation no button on it could satisfy, which is exactly what
the check was added to prevent.
Move it to the three places every popup confirmation actually passes through:
the openInNewWindowWithConfirmation() entry point, showOpenConfirmation()
(reached directly by the superseded and blocked-popup fallbacks, which bypass
that entry), and showBlockedPopupFallback(). Guarding the choke points rather
than the callers is the difference between covering the paths I remembered and
covering all of them -- most of the repeat findings on this branch have been
the former.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 28 ++++++++++++++-----
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 66152071d75..c0a8616afd8 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7332,13 +7332,6 @@ && openUrlOnMainThread(url, false, true)) {
// Sheet is a compatibility fallback the caller never requested, so
// promising a new window there would describe behavior they did not
// choose.
- if (!isMainThreadBridgeAvailable()) {
- // No page-side channel at all, so no button could make this work.
- // Say so instead of showing a confirmation that cannot succeed.
- _log("execute(): no page-side channel on this host bundle, cannot open " + url);
- showCannotOpenMessage(url);
- return;
- }
openInNewWindowWithConfirmation(url, EXECUTE_TARGET_BLANK.equals(target)
? "Open this link in a new window?"
: "Open this link?");
@@ -7355,6 +7348,15 @@ && openUrlOnMainThread(url, false, true)) {
* worse than the Sheet.
*/
private void openInNewWindowWithConfirmation(final String url, final String prompt) {
+ if (!isMainThreadBridgeAvailable()) {
+ // Guarded here rather than at each caller: every popup path funnels
+ // through this method, and putting it in openExternalUrl() alone
+ // left the local-path fallback and the download legs prompting for
+ // something no button could deliver.
+ _log("execute(): no page-side channel on this host bundle, cannot open " + url);
+ showCannotOpenMessage(url);
+ return;
+ }
if (isGestureBackedHookAvailable() && popupReservedForGeneration != gestureGeneration) {
// Tied to the interaction rather than to any drain of it -- see the
// field. Never cleared; the next interaction simply carries a
@@ -7401,6 +7403,13 @@ public void run() {
* not run.
*/
private void showOpenConfirmation(final String url, final String prompt) {
+ if (!isMainThreadBridgeAvailable()) {
+ // Reached directly by the superseded and blocked-popup fallbacks,
+ // which bypass the entry point above.
+ _log("execute(): no page-side channel on this host bundle, cannot open " + url);
+ showCannotOpenMessage(url);
+ return;
+ }
createConfirmationSheet("Open Link", prompt,
shortenUrlForDisplay(url), new Runnable() {
@Override
@@ -7453,6 +7462,11 @@ public void run() {
* tab, which needs no activation and so cannot be blocked in turn.
*/
private void showBlockedPopupFallback(final String url) {
+ if (!isMainThreadBridgeAvailable()) {
+ _log("execute(): no page-side channel on this host bundle, cannot open " + url);
+ showCannotOpenMessage(url);
+ return;
+ }
createConfirmationSheet("Open Link",
"Your browser blocked the new window."
+ " Open this link in the current tab instead?",
From 3f6fa2f1950084ee8a6f130c21689365a8754cf2 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:24:53 +0300
Subject: [PATCH 34/44] JS port: hand aged-out gesture hooks to their fallback
The growth backstop dropped hooks older than eight generations silently. That
threshold is only about four rapid clicks, since press and release each
advance a generation, so it is reachable in ordinary use -- and the drop left
the execute() request with no popup, no Sheet and no trace.
Pruning now invokes the hook's superseded fallback, which asks, exactly as a
supersession does.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/impl/html5/HTML5Implementation.java | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index c0a8616afd8..e7b3140d211 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -669,11 +669,19 @@ private static class GestureHook {
*/
private void addGestureOnlyHook(JSRunnable r, Runnable superseded) {
// Backstop against unbounded growth: a hook whose interaction is long
- // past can no longer be drained, which only happens if the EDT took
- // longer than the whole 5s drain burst to reach us.
+ // past can no longer be drained. Eight generations is only about four
+ // rapid clicks, since press and release each advance one, so this is
+ // reachable in ordinary use -- drop the hook, but hand it to its
+ // fallback rather than losing the request. Silently discarding it left
+ // an execute() with no popup, no Sheet and no trace.
for (int i = gestureOnlyHooks.size() - 1; i >= 0; i--) {
- if (gestureGeneration - gestureOnlyHooks.get(i).generation > 8) {
+ GestureHook stale = gestureOnlyHooks.get(i);
+ if (gestureGeneration - stale.generation > 8) {
gestureOnlyHooks.remove(i);
+ _log("execute(): a queued open aged out before its drain ran");
+ if (stale.superseded != null) {
+ callSerially(stale.superseded);
+ }
}
}
gestureOnlyHooks.add(new GestureHook(gestureGeneration, r, superseded));
From f35eab2819d5fd8d8048351a482dacd7fd77ae6a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:35:55 +0300
Subject: [PATCH 35/44] JS port: do not fire a data: download that registration
already performed
browser_bridge.js's __cn1_register_save_blob_dataurl__ clicks the download
anchor immediately and only stashes a copy in case that click was blocked.
Marking the handler as registered therefore made the later hook or Sheet fire
the stash as well, downloading the file twice whenever the immediate click
succeeded -- which its own comment says is the common case.
Return after registering instead. That also drops the older behavior of
falling through to window.open() on the data: URL, which opened a spurious tab
browsers block for top-level data: navigation, so the path now performs
exactly one download and nothing else.
Verified against the bridge source rather than inferred: both register
handlers fire eagerly and stash, and neither reports whether the eager click
was allowed, so firing the stash unconditionally can only be right when the
click failed -- which this side cannot observe.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 20 +++++++++----------
1 file changed, 9 insertions(+), 11 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index e7b3140d211..6ec0de8c63d 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7558,17 +7558,15 @@ public void execute(String rawUrl) {
// rule as the javascript: payload and the storage key -- normalize
// what the browser parses, never what it carries.
registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, rawUrl);
- // A handler is registered now, exactly as in the Blob case, so the
- // download path below must FIRE it. Leaving this false sent the
- // confirmed OK to window.open() on the data: URL instead -- a tab
- // rather than the download the Sheet advertised, and nothing at all
- // where the browser blocks top-level data: navigation.
- useBlobHandler = true;
- //popover.setContents("");
- buttonText = "Click to Download "+(fileName!=null?fileName:"File");
- nativeButton.setText(buttonText);
- nativeButton.setMaterialIcon(FontImage.MATERIAL_SAVE);
- //icon = "save-file";
+ // Registration IS the download: browser_bridge.js's
+ // __cn1_register_save_blob_dataurl__ clicks the anchor immediately
+ // and only stashes a copy in case that click was blocked. So return
+ // here. Firing the stash as well downloads the file twice, and the
+ // old behavior of falling through to window.open() on the data: URL
+ // opened a spurious tab the browser then blocked. Nothing further
+ // to do on this path.
+ _log("execute(): data: URL handed to the download handler");
+ return;
} else if (isExternalUrl(url)) {
// Reached only by URLs the data: branch above did not claim.
// isExternalUrl() is true for data: as well -- it does carry a
From 57ec987126936eeeec5030eea83dc467ff6ac82a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:45:39 +0300
Subject: [PATCH 36/44] JS port: own the data: download instead of firing the
bridge's stash
Returning after registration removed the retry the bridge stashes a handler
for. Firing that stash instead downloads twice whenever the eager click
succeeded. Neither is right, because a.click() reports its outcome to nobody
and browser_bridge.js ships from the translator, so this side cannot tell the
two cases apart or stop the eager click.
So do the download here: build and click the anchor through eval-on-main,
exactly the technique the bridge uses. There is then no eager click to
duplicate and no stash left unfired, and the click can ride a pending hook or,
failing that, a confirmation Sheet the user drives -- which is the retry the
stash existed to provide. A bundle without eval-on-main still falls back to
the bridge's register handler, one attempt and no retry, which is all it can
manage.
Also repairs two malformed javadoc comments this branch introduced, where an
inserted block ended with the same two lines it was anchored on and duplicated
them. Harmless to javac, since the stray marker sits inside a comment, which
is why the compile never caught it.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 75 ++++++++++++++++---
1 file changed, 64 insertions(+), 11 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 6ec0de8c63d..2ecba678048 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7012,7 +7012,69 @@ public void actionPerformed(ActionEvent evt) {
}
/**
- * A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one /**
+ * Downloads a {@code data:} URL by clicking a generated anchor on the page.
+ *
+ *
The same technique {@code browser_bridge.js} uses, run from here so it
+ * happens exactly ONCE and at a moment we choose. Its
+ * {@code __cn1_register_save_blob_dataurl__} fires the click eagerly and
+ * stashes a copy for a later retry, which is only resolvable by knowing
+ * whether the eager click was allowed -- and {@code a.click()} reports that
+ * to nobody. Doing it ourselves sidesteps the question: there is no eager
+ * click to duplicate, and no stash left unfired.
+ */
+ private boolean downloadDataUrlOnMainThread(String dataUrl, String fileName) {
+ String script = "if (typeof document === 'undefined') {"
+ + " throw new Error('cn1: not running on the page'); }"
+ + "var cn1a = document.createElement('a');"
+ + "cn1a.href = " + toJavaScriptStringLiteral(dataUrl) + ";"
+ + "cn1a.download = " + toJavaScriptStringLiteral(fileName) + ";"
+ + "document.body.appendChild(cn1a);"
+ + "cn1a.click();"
+ + "document.body.removeChild(cn1a);";
+ try {
+ eval_(script);
+ return true;
+ } catch (Throwable t) {
+ _log("Failed to download a data: URL on the main thread: " + t);
+ return false;
+ }
+ }
+
+ /**
+ * Runs a {@code data:} download once, riding a pending hook when there is
+ * one and otherwise asking, so a blocked attempt still has a user-driven
+ * retry. Uses the shared hook queue: a download is gated on engagement
+ * rather than transient activation, exactly as the Blob leg is.
+ */
+ private void downloadDataUrl(final String dataUrl, final String fileName) {
+ if (!isMainThreadBridgeAvailable()) {
+ // No page-side channel of our own, so the bridge's register handler
+ // -- whose eager click is its own trigger -- is all that is left.
+ // One attempt and no retry, which is what this bundle can manage.
+ _log("execute(): no page-side channel, leaving the data: download to the bridge");
+ registerSaveBlobHandlerDataUrl(fileName, dataUrl);
+ return;
+ }
+ final JSRunnable click = new JSRunnable() {
+ @Override
+ public void run() {
+ downloadDataUrlOnMainThread(dataUrl, fileName);
+ }
+ };
+ if (isBacksideHookAvailable()) {
+ addBacksideHook(click);
+ return;
+ }
+ createConfirmationSheet("Download file", "Download " + fileName + " now?",
+ null, new Runnable() {
+ @Override
+ public void run() {
+ addBacksideHook(click);
+ }
+ }).show();
+ }
+
+ /**
* A raw URL has no spaces for {@code SpanLabel} to wrap on, so putting one
* in the confirmation Sheet blew the content pane's preferred width past the
* screen and pushed the buttons out of reach. Shorten it for display; the
@@ -7261,7 +7323,6 @@ private static boolean startsWithAuthorityMarker(String url) {
}
/**
- * True when {@code url} carries exactly {@code scheme}, compared without /**
* True when {@code url} carries exactly {@code scheme}, compared without
* regard to case since URI schemes are case-insensitive. Guards the
* {@code javascript:} and {@code data:} branches of {@link #execute(String)},
@@ -7557,15 +7618,7 @@ public void execute(String rawUrl) {
// download's payload, not something we are only classifying. Same
// rule as the javascript: payload and the storage key -- normalize
// what the browser parses, never what it carries.
- registerSaveBlobHandlerDataUrl(fileName == null ? "download":fileName, rawUrl);
- // Registration IS the download: browser_bridge.js's
- // __cn1_register_save_blob_dataurl__ clicks the anchor immediately
- // and only stashes a copy in case that click was blocked. So return
- // here. Firing the stash as well downloads the file twice, and the
- // old behavior of falling through to window.open() on the data: URL
- // opened a spurious tab the browser then blocked. Nothing further
- // to do on this path.
- _log("execute(): data: URL handed to the download handler");
+ downloadDataUrl(rawUrl, fileName == null ? "download" : fileName);
return;
} else if (isExternalUrl(url)) {
// Reached only by URLs the data: branch above did not claim.
From e31a6178c93f8094521dd541b84a042a20a36063 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:51:59 +0300
Subject: [PATCH 37/44] JS port: make sure a confirmed data: download has a
drain to run on
addBacksideHook() schedules nothing, unlike addGestureOnlyHook(), so a hook
queued after the last drain of an interaction has already run waits for one
that will never come. Safari schedules a single 75/300ms drain, so an EDT
slower than that lands exactly there -- and removing the bridge's eager click
took away the earlier attempt that used to cover it, leaving a confirmed
download that never starts.
Queue the click through addBacksideHookEnsuringDrain(), which schedules a
drain when neither a pending gesture drain nor the polling interval will
provide one.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 24 +++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 2ecba678048..806a4dd0529 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7011,6 +7011,23 @@ public void actionPerformed(ActionEvent evt) {
sheet.show();
}
+ /**
+ * Queues a hook on the shared queue and makes sure something will drain it.
+ *
+ *
Unlike {@link #addGestureOnlyHook(JSRunnable, Runnable)}, plain
+ * {@link #addBacksideHook(JSRunnable)} schedules nothing: the hook waits for
+ * a drain that may already have run. Safari schedules a single 75/300ms
+ * drain per interaction, so an EDT slower than that arrives with none left
+ * and the hook sits forever. Callers that have no other attempt to fall
+ * back on need this.
+ */
+ private void addBacksideHookEnsuringDrain(JSRunnable r) {
+ addBacksideHook(r);
+ if (pendingGestureDrains <= 0 && backsideHooksIntervalHandle == 0) {
+ runBacksideHooksInTimeout(0);
+ }
+ }
+
/**
* Downloads a {@code data:} URL by clicking a generated anchor on the page.
*
@@ -7062,14 +7079,17 @@ public void run() {
}
};
if (isBacksideHookAvailable()) {
- addBacksideHook(click);
+ addBacksideHookEnsuringDrain(click);
return;
}
createConfirmationSheet("Download file", "Download " + fileName + " now?",
null, new Runnable() {
@Override
public void run() {
- addBacksideHook(click);
+ // Ensuring a drain matters most here: the user confirmed, and
+ // removing the bridge's eager click left no earlier attempt to
+ // rescue a hook that never runs.
+ addBacksideHookEnsuringDrain(click);
}
}).show();
}
From 68fa4db6f5f6b94e4f7d7b9a74030661a5ee8a57 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:58:26 +0300
Subject: [PATCH 38/44] JS port: treat a press and its release as one gesture
generation
Both edges advanced the generation, so an execute() issued from a
pointerPressed listener was tagged with the press and then superseded by its
own release -- which for any click shorter than the 300ms drain delay, i.e.
nearly all of them, meant the link went to a confirmation Sheet instead of
opening on the release's fresh activation.
Advance only on press and key down. A release still schedules drains and still
refreshes the window's activation, but under the interaction already in
progress, so a press-time hook is servable by the release's drain -- which
carries the better activation of the two. A genuinely separate interaction
still supersedes, since it begins with its own press.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 29 ++++++++++++++-----
1 file changed, 22 insertions(+), 7 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 806a4dd0529..9397c0edeab 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -852,11 +852,26 @@ private static int safariBacksideHookDelay() {
private native static int _safariBacksideHookDelay();
private void installBacksideHooksInUserInteraction() {
- // A distinct interaction, so a popup reserved against the previous one
- // no longer applies -- see popupReservedForGeneration -- and the
- // previous one's outstanding drains cannot serve this one's hooks.
- gestureGeneration++;
- pendingGestureDrains = 0;
+ installBacksideHooksInUserInteraction(true);
+ }
+
+ /**
+ * @param newInteraction true for a press or key down, which begins a new
+ * interaction; false for the matching release, which belongs to the
+ * interaction already in progress. A release refreshes the window's
+ * activation and schedules more drains, but it is not a new gesture: a
+ * press-time execute() tagged with the current generation must still be
+ * servable by the release's drain, and a click shorter than the 300ms
+ * delay -- the normal case -- always releases first.
+ */
+ private void installBacksideHooksInUserInteraction(boolean newInteraction) {
+ if (newInteraction) {
+ // A distinct interaction, so a popup reserved against the previous
+ // one no longer applies -- see popupReservedForGeneration -- and the
+ // previous one's outstanding drains cannot serve this one's hooks.
+ gestureGeneration++;
+ pendingGestureDrains = 0;
+ }
if (isIOS() || isSafari()) {
debugLog("Installing backside hooks with delay "+safariBacksideHookDelay());
runBacksideHooksInTimeout(safariBacksideHookDelay());
@@ -1906,7 +1921,7 @@ public void handleEvent(Event evt) {
pointerState.setMouseDown(false);
pointerState.setLastTouchUpPosition(x, y);
- installBacksideHooksInUserInteraction();
+ installBacksideHooksInUserInteraction(false);
applyMouseMetadata(me);
final Runnable releaseDispatch = new Runnable() {
@@ -2067,7 +2082,7 @@ public void handleEvent(Event evt) {
pointerState.setGrabbedDrag(false);
TouchEvent me = (TouchEvent)evt;
- installBacksideHooksInUserInteraction();
+ installBacksideHooksInUserInteraction(false);
nativeCallSerially(new Runnable() {
@Override
public void run() {
From d63cc66a45416e8c00dca128441e8ac8d02f342f Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 10:59:43 +0300
Subject: [PATCH 39/44] JS port: do not let a self-scheduled drain claim a user
gesture
backsideHooksSemaphore is what isGestureBackedHookAvailable() reads to decide
a popup may be attempted, and both drain-rescue paths added recently raised it
by calling runBacksideHooksInTimeout(). Those drains carry no activation, so
the port could believe a gesture was pending, attempt window.open(), and have
it blocked -- exactly the outcome the gesture check exists to avoid, and with
the blocked-popup indicator the auto path was written to prevent.
Only interaction-scheduled drains raise the semaphore now. Self-scheduled ones
still tag their generation and still count toward pendingGestureDrains, which
is what makes them able to run a stranded hook at all; they simply no longer
assert a gesture that does not exist.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 25 ++++++++++++++++---
1 file changed, 21 insertions(+), 4 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 9397c0edeab..e05a94108a2 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -693,7 +693,7 @@ private void addGestureOnlyHook(JSRunnable r, Runnable superseded) {
// link would do nothing at all. Schedule one. The activation is
// probably gone by now, but an attempt that reports failure beats
// silence: the caller logs it, or falls back to the Sheet.
- runBacksideHooksInTimeout(0);
+ runBacksideHooksInTimeout(0, false);
}
}
@@ -756,7 +756,22 @@ public boolean isBacksideHookAvailable() {
* @param timeout
*/
private void runBacksideHooksInTimeout(int timeout) {
- backsideHooksSemaphore++;
+ runBacksideHooksInTimeout(timeout, true);
+ }
+
+ /**
+ * @param fromInteraction true when a real pointer or key event scheduled
+ * this drain. Only those raise {@link #backsideHooksSemaphore}, because
+ * that is what {@link #isGestureBackedHookAvailable()} reads to decide a
+ * popup may be attempted. A drain we schedule ourselves to rescue a
+ * stranded hook carries no activation, so counting it there would claim a
+ * gesture that does not exist and send a popup to be blocked instead of
+ * to the Sheet.
+ */
+ private void runBacksideHooksInTimeout(int timeout, final boolean fromInteraction) {
+ if (fromInteraction) {
+ backsideHooksSemaphore++;
+ }
//_log("Incrementing backsideHooksSemaphore: "+backsideHooksSemaphore);
// Remember which interaction scheduled this drain, so it only runs the
// activation-dependent hooks that same interaction queued.
@@ -765,7 +780,9 @@ private void runBacksideHooksInTimeout(int timeout) {
Window.setTimeout(new TimerHandler() {
@Override
public void onTimer() {
- backsideHooksSemaphore--;
+ if (fromInteraction) {
+ backsideHooksSemaphore--;
+ }
if (generation == gestureGeneration) {
pendingGestureDrains--;
}
@@ -7039,7 +7056,7 @@ public void actionPerformed(ActionEvent evt) {
private void addBacksideHookEnsuringDrain(JSRunnable r) {
addBacksideHook(r);
if (pendingGestureDrains <= 0 && backsideHooksIntervalHandle == 0) {
- runBacksideHooksInTimeout(0);
+ runBacksideHooksInTimeout(0, false);
}
}
From 31f75bbc2be694c756232aa006e2de8fb16e2fe4 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:09:19 +0300
Subject: [PATCH 40/44] JS port: one generation per keystroke, and page-side
scripts in function scope
The keyboard adapter installs hooks on keydown, keypress and keyup, all
through the generation-advancing overload, so a single keystroke advanced it
three times and an execute() from a keyPressed handler was superseded by its
own keypress or keyup. Mouse and touch releases already passed false; keyup
and keypress now do too, leaving keydown to begin the interaction.
__cn1_eval_on_main__ evaluates through indirect eval, so the generated scripts
ran in global scope: "var cn1a" and "var cn1w" became properties of the
embedding page's global object, clobbering anything of those names, and a page
declaring a top-level let or const cn1w made the eval throw outright, breaking
the default popup path. Every generated script is now wrapped in a function.
Checked by running the emitted scripts under node: all four parse, none leaks
a global, and against a page that declares "let cn1w" the old form throws
"Identifier 'cn1w' has already been declared" while the wrapped form runs.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../codename1/impl/html5/HTML5Implementation.java | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index e05a94108a2..200e638d6ea 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -2525,7 +2525,7 @@ public void handleEvent(Event evt) {
JavaScriptKeyboardInteractionAdapter.handleKeyUp(new JavaScriptKeyboardInteractionAdapter.BacksideHooks() {
@Override
public void installBacksideHooksInUserInteraction() {
- HTML5Implementation.this.installBacksideHooksInUserInteraction();
+ HTML5Implementation.this.installBacksideHooksInUserInteraction(false);
}
}, new JavaScriptKeyboardInteractionAdapter.KeyDispatch() {
@Override
@@ -2606,7 +2606,7 @@ public boolean isEditing() {
}, new JavaScriptKeyboardInteractionAdapter.BacksideHooks() {
@Override
public void installBacksideHooksInUserInteraction() {
- HTML5Implementation.this.installBacksideHooksInUserInteraction();
+ HTML5Implementation.this.installBacksideHooksInUserInteraction(false);
}
}, new JavaScriptKeyboardInteractionAdapter.KeyDispatch() {
@Override
@@ -6976,7 +6976,12 @@ private boolean openUrlOnMainThread(String url, boolean sameWindow, boolean fall
+ SEVER_OPENER;
}
try {
- eval_(script);
+ // Wrapped in a function: __cn1_eval_on_main__ runs this through
+ // indirect eval, so a bare "var cn1w" would land on the page's
+ // global object -- clobbering an identically named global of the
+ // embedding page, and throwing outright against a top-level let or
+ // const of that name, which would break the default popup path.
+ eval_("(function(){" + script + "})();");
return true;
} catch (Throwable t) {
_log("Failed to open URL on the main thread: " + t);
@@ -7081,7 +7086,9 @@ private boolean downloadDataUrlOnMainThread(String dataUrl, String fileName) {
+ "cn1a.click();"
+ "document.body.removeChild(cn1a);";
try {
- eval_(script);
+ // Function-scoped for the same reason as the open scripts: indirect
+ // eval would otherwise leak cn1a onto the page's globals.
+ eval_("(function(){" + script + "})();");
return true;
} catch (Throwable t) {
_log("Failed to download a data: URL on the main thread: " + t);
From 49a35dae03dde4032e6f77452f1e0d069928ce9b Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:18:12 +0300
Subject: [PATCH 41/44] JS port: resolve same-scheme slashless URLs against the
page, not as authorities
A slashless special-scheme URL is only an authority form when its scheme
differs from the page's. When it matches, it is a relative reference: on an
https page https:example.com/p resolves to the PAGE's host with example.com
beginning the path. The Sheet named example.com as the destination, which is
simply false -- and in the direction that matters, since it also meant
https:trusted@evil.example/p was displayed as evil.example when the browser
would never go there.
Compare against the page's scheme, which the port already reads through
mainLocationPart(), and show the page's own host for the relative case.
Cross-scheme slashless forms keep their authority parsing, so
http:trusted@evil.example/path still displays evil.example.
Confirmed the resolution rules against node's URL parser first:
https:example.com/path + base https://app.example.org/base/
-> https://app.example.org/base/example.com/path
http:example.com/path + same base
-> http://example.com/path
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 200e638d6ea..98d0ee0f756 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7168,6 +7168,21 @@ static String shortenUrlForDisplay(String url) {
// http:host/path, and even http:/host/path, with host as the
// authority. Without this the slashless form would skip the
// parsing below and display its leading user info.
+ //
+ // UNLESS the scheme matches the page's own. Then it is a
+ // relative reference, not an authority: on an https page
+ // https:example.com/p resolves under the page's host, with
+ // example.com as the start of the PATH. Naming example.com
+ // as the destination would be simply false.
+ String scheme = url.substring(0, colon);
+ String page = pageScheme();
+ if (page != null && page.equalsIgnoreCase(scheme)) {
+ String host = mainLocationPart("host");
+ if (host != null && host.length() > 0) {
+ return host + "/" + DISPLAY_URL_ELLIPSIS;
+ }
+ return ellipsizeTail(url);
+ }
rest = url.substring(colon + 1);
// Only reached when the scheme is special; the separator
// skip below covers http:/host as well as http:host.
@@ -7224,6 +7239,23 @@ static String shortenUrlForDisplay(String url) {
return hasPath ? authority + "/" + DISPLAY_URL_ELLIPSIS : authority;
}
+ /**
+ * The page's own scheme, without the trailing colon, or null if unreadable.
+ */
+ private static String pageScheme() {
+ try {
+ String protocol = mainLocationPart("protocol");
+ if (protocol == null) {
+ return null;
+ }
+ return protocol.endsWith(":")
+ ? protocol.substring(0, protocol.length() - 1)
+ : protocol;
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+
/**
* The WHATWG URL "special" schemes, which browsers parse with an authority
* even when the {@code //} is missing.
From 910f704e21a6d5875701be0d5d8e4717a2fb39dd Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:29:33 +0300
Subject: [PATCH 42/44] JS port: let a separator pair outrank the same-scheme
relative rule
Two separators after the colon, in any mix of "/" and "\", are an authority
marker even when the scheme matches the page's, so https:\\evil.example/path
navigates to evil.example from an https page. The same-scheme check I added
last commit ran first and returned the app's own host, naming a destination
the browser was not going to -- the dangerous direction, and introduced by the
fix for the harmless one.
Check for the marker before applying the relative rule. Verified against
node's URL parser as the oracle rather than against my own expectations: for
eleven shapes, the host shown now equals the host the parser resolves.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/impl/html5/HTML5Implementation.java | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 98d0ee0f756..889f33843e5 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7176,7 +7176,15 @@ static String shortenUrlForDisplay(String url) {
// as the destination would be simply false.
String scheme = url.substring(0, colon);
String page = pageScheme();
- if (page != null && page.equalsIgnoreCase(scheme)) {
+ // ...and only when fewer than two separators follow the
+ // colon. Two of them, in any mix of "/" and "\\", are an
+ // authority marker that outranks the same-scheme rule:
+ // https:\\\\evil.example/p navigates to evil.example even
+ // from an https page, so claiming the app's own host there
+ // would name a destination the browser is not going to.
+ boolean authorityFollows =
+ startsWithAuthorityMarker(url.substring(colon + 1));
+ if (!authorityFollows && page != null && page.equalsIgnoreCase(scheme)) {
String host = mainLocationPart("host");
if (host != null && host.length() > 0) {
return host + "/" + DISPLAY_URL_ELLIPSIS;
From 0f182f3237d02104183e1ca2d9ff604c589dd269 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:34:03 +0300
Subject: [PATCH 43/44] JS port: match the browser's host resolution across a
1689-shape corpus
After the ninth review round on URL display, I stopped fixing shapes one at a
time and diffed the whole thing against node's URL parser instead. That found
42 divergences nobody had reported yet, in two classes.
A string with no scheme resolves against the page, so its destination host is
the page's own and the text is a path beneath it. Showing the text alone
invited reading "evil.example" as the destination when the link actually goes
to /evil.example. Those now render as the page host plus the path,
or the host and an ellipsis when too long.
file: is WHATWG-special for parsing even though this port routes it to
storage, so file:\\host/p carries a real authority. isSpecialScheme() now says
so; routing is unaffected, since isExternalUrl() decides that separately.
Consolidating the scheme grammar into schemeColon() removed the last four:
a colon inside a path, as in /host:8443/p or ./user:pw@host, was being read as
a scheme terminator. Both isExternalUrl() and the display parser now share
that one definition rather than carrying a copy each.
The corpus covers 13 schemes x 9 separator spellings x 6 authority shapes x 4
tails, plus whitespace and degenerate forms. For all 1689 inputs with a
resolvable host, the host shown now equals the host node resolves.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 72 ++++++++++++++-----
1 file changed, 53 insertions(+), 19 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 889f33843e5..2490cb15878 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7162,7 +7162,7 @@ static String shortenUrlForDisplay(String url) {
rest = url.substring(schemeEnd + 3);
special = isSpecialScheme(url.substring(0, schemeEnd));
} else {
- int colon = url.indexOf(':');
+ int colon = schemeColon(url);
if (colon > 0 && isSpecialScheme(url.substring(0, colon))) {
// A special scheme tolerates missing slashes: browsers parse
// http:host/path, and even http:/host/path, with host as the
@@ -7195,9 +7195,24 @@ static String shortenUrlForDisplay(String url) {
// Only reached when the scheme is special; the separator
// skip below covers http:/host as well as http:host.
special = true;
- } else {
- // No authority to protect (mailto:, tel:, a bare path).
+ } else if (colon > 0) {
+ // A scheme we do not parse an authority for (mailto:, tel:).
return ellipsizeTail(url);
+ } else {
+ // No scheme at all, so the browser resolves it against the
+ // page: the destination host is the page's own, and the
+ // text is a path under it. Returning the text alone invited
+ // reading "evil.example" as a host when the link goes to
+ // /evil.example.
+ String host = mainLocationPart("host");
+ if (host == null || host.length() == 0) {
+ return ellipsizeTail(url);
+ }
+ String path = url.startsWith("/") ? url : "/" + url;
+ String combined = host + path;
+ return combined.length() <= MAX_DISPLAY_URL_LENGTH
+ ? combined
+ : host + "/" + DISPLAY_URL_ELLIPSIS;
}
}
}
@@ -7247,6 +7262,32 @@ static String shortenUrlForDisplay(String url) {
return hasPath ? authority + "/" + DISPLAY_URL_ELLIPSIS : authority;
}
+ /**
+ * Index of the colon terminating a syntactically valid scheme, or -1 when
+ * the string carries none.
+ *
+ *
Shared so "does this have a scheme" is answered once. A colon inside a
+ * path -- {@code /host:8443/p}, {@code ./user:pw@host} -- is not one, and
+ * treating it as one made those read as unparsed schemes rather than as the
+ * page-relative references they are.
+ */
+ private static int schemeColon(String url) {
+ int colon = url.indexOf(':');
+ if (colon < 1) {
+ return -1;
+ }
+ // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), per RFC 3986.
+ for (int i = 0; i < colon; i++) {
+ char c = url.charAt(i);
+ boolean alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ boolean extra = i > 0 && ((c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.');
+ if (!alpha && !extra) {
+ return -1;
+ }
+ }
+ return colon;
+ }
+
/**
* The page's own scheme, without the trailing colon, or null if unreadable.
*/
@@ -7273,7 +7314,12 @@ private static boolean isSpecialScheme(String scheme) {
|| "https".equalsIgnoreCase(scheme)
|| "ws".equalsIgnoreCase(scheme)
|| "wss".equalsIgnoreCase(scheme)
- || "ftp".equalsIgnoreCase(scheme);
+ || "ftp".equalsIgnoreCase(scheme)
+ // Special for PARSING, which is all this governs. Routing still
+ // sends file: to storage -- see isExternalUrl() -- but when one
+ // does reach the Sheet, file:\\\\host/p carries a real authority
+ // and must be read as one.
+ || "file".equalsIgnoreCase(scheme);
}
/**
@@ -7453,25 +7499,13 @@ private static boolean isExternalUrl(String url) {
// Scheme relative -- the page's own scheme applies.
return true;
}
- int colon = url.indexOf(':');
// RFC 3986 allows a single ALPHA scheme, so x:payload is a legitimate
- // deep link. The usual reason to demand two characters is to avoid
- // reading a Windows drive letter as a scheme, which cannot arise here:
- // getFileSystemSeparator() is '/' on this port, so no storage path it
- // hands us is drive qualified.
+ // deep link. A colon inside a path is not a scheme -- schemeColon()
+ // holds that grammar for every caller.
+ int colon = schemeColon(url);
if (colon < 1) {
return false;
}
- // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), per RFC 3986. A
- // path that merely happens to contain a colon fails this.
- for (int i = 0; i < colon; i++) {
- char c = url.charAt(i);
- boolean alpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
- boolean extra = i > 0 && ((c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.');
- if (!alpha && !extra) {
- return false;
- }
- }
// equalsIgnoreCase rather than toLowerCase(): case folding a scheme
// through the default locale turns "FILE" into "fIle" under a Turkish
// locale and would classify a file: URL as external.
From 1c182f80b5b92e8a8d5bd25c3dd12f235450f3d0 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:44:27 +0300
Subject: [PATCH 44/44] JS port: send the data: payload as structured data, not
as eval source
Clicking the anchor ourselves avoided the bridge's duplicate download and
restored the retry, but it escaped the whole base64 payload into a JavaScript
literal: several payload-sized copies on the worker, then a page-side parse of
a multi-megabyte string as source. That is a worse cost than either problem it
solved, and on the same payloads the storage gate already refuses to copy.
Three constraints, and the current bridge can satisfy two: download once,
retry when blocked, do not copy the payload. Registering fires eagerly, so
re-firing duplicates; clicking ourselves costs the copies. Register and
return, then -- one download, and the payload travels once as structured
host-call data.
The retry is what is given up. Recovering all three needs
__cn1_register_save_blob_dataurl__ to accept a no-eager-fire flag, which lives
in the translator repo rather than here.
Drops downloadDataUrlOnMainThread() and addBacksideHookEnsuringDrain(), the
latter having existed only to schedule a drain for that click. Net 63 lines
lighter.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../impl/html5/HTML5Implementation.java | 95 ++++---------------
1 file changed, 16 insertions(+), 79 deletions(-)
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 2490cb15878..9b6e239c925 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -7048,89 +7048,26 @@ public void actionPerformed(ActionEvent evt) {
sheet.show();
}
- /**
- * Queues a hook on the shared queue and makes sure something will drain it.
- *
- *
Unlike {@link #addGestureOnlyHook(JSRunnable, Runnable)}, plain
- * {@link #addBacksideHook(JSRunnable)} schedules nothing: the hook waits for
- * a drain that may already have run. Safari schedules a single 75/300ms
- * drain per interaction, so an EDT slower than that arrives with none left
- * and the hook sits forever. Callers that have no other attempt to fall
- * back on need this.
- */
- private void addBacksideHookEnsuringDrain(JSRunnable r) {
- addBacksideHook(r);
- if (pendingGestureDrains <= 0 && backsideHooksIntervalHandle == 0) {
- runBacksideHooksInTimeout(0, false);
- }
- }
/**
- * Downloads a {@code data:} URL by clicking a generated anchor on the page.
+ * Hands a {@code data:} download to the bridge's structured handler.
*
- *
The same technique {@code browser_bridge.js} uses, run from here so it
- * happens exactly ONCE and at a moment we choose. Its
- * {@code __cn1_register_save_blob_dataurl__} fires the click eagerly and
- * stashes a copy for a later retry, which is only resolvable by knowing
- * whether the eager click was allowed -- and {@code a.click()} reports that
- * to nobody. Doing it ourselves sidesteps the question: there is no eager
- * click to duplicate, and no stash left unfired.
- */
- private boolean downloadDataUrlOnMainThread(String dataUrl, String fileName) {
- String script = "if (typeof document === 'undefined') {"
- + " throw new Error('cn1: not running on the page'); }"
- + "var cn1a = document.createElement('a');"
- + "cn1a.href = " + toJavaScriptStringLiteral(dataUrl) + ";"
- + "cn1a.download = " + toJavaScriptStringLiteral(fileName) + ";"
- + "document.body.appendChild(cn1a);"
- + "cn1a.click();"
- + "document.body.removeChild(cn1a);";
- try {
- // Function-scoped for the same reason as the open scripts: indirect
- // eval would otherwise leak cn1a onto the page's globals.
- eval_("(function(){" + script + "})();");
- return true;
- } catch (Throwable t) {
- _log("Failed to download a data: URL on the main thread: " + t);
- return false;
- }
- }
-
- /**
- * Runs a {@code data:} download once, riding a pending hook when there is
- * one and otherwise asking, so a blocked attempt still has a user-driven
- * retry. Uses the shared hook queue: a download is gated on engagement
- * rather than transient activation, exactly as the Blob leg is.
+ *
Three constraints apply and the current bridge can satisfy only two.
+ * The download must happen once; a blocked attempt should be retryable; and
+ * the payload must not be copied more than necessary. Registering fires the
+ * click eagerly, so re-firing the stash duplicates it. Clicking the anchor
+ * ourselves through eval-on-main avoids that and restores the retry, but
+ * escapes a multi-megabyte base64 string into JavaScript source, builds
+ * several payload-sized copies of it and makes the page parse it as code.
+ *
+ *
So: register and return. One download, and the payload travels once as
+ * structured host-call data rather than as source text. The retry is what is
+ * given up, and recovering it needs a bridge-side change --
+ * {@code __cn1_register_save_blob_dataurl__} would have to accept a
+ * no-eager-fire flag -- which lives in the translator, not here.
*/
- private void downloadDataUrl(final String dataUrl, final String fileName) {
- if (!isMainThreadBridgeAvailable()) {
- // No page-side channel of our own, so the bridge's register handler
- // -- whose eager click is its own trigger -- is all that is left.
- // One attempt and no retry, which is what this bundle can manage.
- _log("execute(): no page-side channel, leaving the data: download to the bridge");
- registerSaveBlobHandlerDataUrl(fileName, dataUrl);
- return;
- }
- final JSRunnable click = new JSRunnable() {
- @Override
- public void run() {
- downloadDataUrlOnMainThread(dataUrl, fileName);
- }
- };
- if (isBacksideHookAvailable()) {
- addBacksideHookEnsuringDrain(click);
- return;
- }
- createConfirmationSheet("Download file", "Download " + fileName + " now?",
- null, new Runnable() {
- @Override
- public void run() {
- // Ensuring a drain matters most here: the user confirmed, and
- // removing the bridge's eager click left no earlier attempt to
- // rescue a hook that never runs.
- addBacksideHookEnsuringDrain(click);
- }
- }).show();
+ private void downloadDataUrl(String dataUrl, String fileName) {
+ registerSaveBlobHandlerDataUrl(fileName, dataUrl);
}
/**