From c51ddb3d6eae5daf088559982ac7698dfa78356d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:54:45 +0200 Subject: [PATCH 1/5] JavaScript port: loading screen with the app icon and light/dark colours (#6117) Build hints javascript.splash.* set the colours, appearance mode and logo; the theme is written into so a dark app never opens on white, and platformHint.javascript.splashAppearance remembers the app's own choice. Co-Authored-By: Claude Opus 5.5 --- .../impl/html5/HTML5Implementation.java | 28 +++ .../impl/html5/JSOImplementations.java | 11 ++ .../working-with-javascript.html | 9 - .../Working-With-Javascript.asciidoc | 38 +++- .../builders/JavaScriptBuildHints.java | 26 +++ .../builders/JavaScriptBuildHintsTest.java | 23 +++ .../build/shared/BuildHintsDesktop.java | 72 ++++++++ .../translator/JavascriptBundleWriter.java | 169 ++++++++++++++++++ .../src/javascript/index.html | 38 +++- .../JavascriptPageSettingsTest.java | 52 ++++++ 10 files changed, 450 insertions(+), 16 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 7b53f4effe7..e2c0528d312 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 @@ -5349,6 +5349,31 @@ private JavaScriptRuntimeEnvironment createRuntimeEnvironment(final WindowExt wi ); } + /// Remembers the application's light/dark choice for the loading screen of the NEXT launch: + /// `light` or `dark`, or null/anything else to follow the browser again. The page reads the + /// key in `` before any Java runs (`JavascriptBundleWriter.splashTheme` in the + /// ByteCodeTranslator, which also names the key); a build whose `javascript.splash.appearance` + /// is `light` or `dark` ignores it. + private static void setSplashAppearance(String value) { + // The cast stays outside the try: a CHECKCAST is unchecked on ParparVM, so a handler + // around it would never run (scripts/check-cast-semantics.sh). + WindowExt win = (WindowExt) Window.current(); + try { + JSOImplementations.JSStorage storage = win.getLocalStorage(); + if (storage == null) { + return; + } + if ("light".equals(value) || "dark".equals(value)) { + storage.setItem("cn1.splashAppearance", value); + } else { + storage.removeItem("cn1.splashAppearance"); + } + } catch (RuntimeException e) { + // Storage disabled (private mode, a sandboxed frame): the splash follows the browser. + Log.e(e); + } + } + @JSBody(params={"msg"}, script="window.onbeforeunload=function(){return msg;}") private native static void setBeforeUnloadMessage(String msg); @@ -5357,6 +5382,9 @@ private JavaScriptRuntimeEnvironment createRuntimeEnvironment(final WindowExt wi @Override public void setPlatformHint(String key, String value) { + if ("platformHint.javascript.splashAppearance".equalsIgnoreCase(key)) { + setSplashAppearance(value); + } if ("platformHint.javascript.beforeUnloadMessage".equalsIgnoreCase(key)) { if (value == null) { removeBeforeUnload(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java index 0147e4d636e..adaeba3d29d 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java @@ -66,7 +66,18 @@ public interface JSFontMetrics extends JSObject { JSObject getBounds(); } + /// The page's `window.localStorage`, for the few values the page itself reads before any + /// Java runs (the loading screen's remembered appearance). + public interface JSStorage extends JSObject { + void setItem(String key, String value); + + void removeItem(String key); + } + public interface WindowExt extends JSObject { + + @JSProperty + abstract JSStorage getLocalStorage(); @JSProperty diff --git a/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html b/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html index 093262deb4f..551391157dd 100644 --- a/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html +++ b/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html @@ -1,14 +1,5 @@ // Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. -// tag::working-with-javascript-html-001[] -
- - - -

...Loading...

-
-// end::working-with-javascript-html-001[] - // tag::working-with-javascript-html-002[] // end::working-with-javascript-html-002[] diff --git a/docs/developer-guide/Working-With-Javascript.asciidoc b/docs/developer-guide/Working-With-Javascript.asciidoc index a489cc6c3d1..65daf2ce0a1 100644 --- a/docs/developer-guide/Working-With-Javascript.asciidoc +++ b/docs/developer-guide/Working-With-Javascript.asciidoc @@ -262,15 +262,45 @@ This tells Apache to proxy all requests for '/app' to the domain https://www.mye [id="splash_screen", reftext="Customizing the Splash Screen"] === Customizing the splash screen -Since your application may include many resource files, videos, etc., the build-server will generate a splash screen for your app to display while it's loading. This basically shows a progress indicator with your app's icon. +While the application downloads and starts, the page shows a loading screen: the app icon, an animated progress bar and a "Loading" label. It stays up until the first form is shown. -You can customize this splash screen by modifying the HTML source inside the *cn1-splash* `div` tag of your app's index.html file: +Build hints set its look, so the settings survive every build instead of being edited into each generated bundle: -[source,html] +[cols="1,1,3",options="header"] +|=== +|Build hint |Default |Meaning + +|`javascript.splash.appearance` +|`auto` +|`auto` follows the browser's light/dark preference. `light` or `dark` always uses that set of colors, for an app with a single appearance. + +|`javascript.splash.background`, `javascript.splash.textColor`, `javascript.splash.accentColor` +|`#ffffff`, `#666666`, `#0072ff` +|The page background, the label and the progress bar in the light appearance. + +|`javascript.splash.darkBackground`, `javascript.splash.darkTextColor`, `javascript.splash.darkAccentColor` +|`#121212`, `#b3b3b3`, `#4da3ff` +|The same three in the dark appearance. + +|`javascript.splash.logo` +|The app icon +|A different image, as a path relative to the bundle's `index.html`, or `none` for no image. +|=== + +Colors are hex values (`#rgb`, `#rrggbb`, with optional alpha) or CSS color names. The page applies them before it paints anything, and the background stays behind the app until the first form covers it, so a dark app no longer opens on a white page. + +An app that lets the user choose its own appearance can have the next launch's loading screen match that choice, even when it differs from the browser's preference: + +[source,java] ---- -include::../demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html[tag=working-with-javascript-html-001,indent=0] +CN.setProperty("platformHint.javascript.splashAppearance", "dark"); // or "light" +CN.setProperty("platformHint.javascript.splashAppearance", null); // follow the browser again ---- +The choice is kept in the browser's local storage and applies only in `auto` mode. + +For anything the hints don't cover, the markup is the `cn1-splash` `div` in the generated `index.html`; edit it there if you host the bundle yourself. + [id="debugging", reftext="Debugging in Chrome"] === Debugging diff --git a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java index 746b00ba83f..5151b8ce87e 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java @@ -89,6 +89,19 @@ public final class JavaScriptBuildHints { "javascript.native.theme", "javascript.desktopTheme" }; + /// The `javascript.splash.*` hints that style the loading screen, each passed to the + /// translator as `codename1.` (see `JavascriptBundleWriter.SplashSettings`, which + /// validates them). + static final String[] SPLASH_HINTS = { + "javascript.splash.appearance", "javascript.splash.background", + "javascript.splash.textColor", "javascript.splash.accentColor", + "javascript.splash.darkBackground", "javascript.splash.darkTextColor", + "javascript.splash.darkAccentColor", "javascript.splash.logo" + }; + + /// The 192px icon `JavaScriptPwa` writes into every bundle. + static final String SPLASH_DEFAULT_LOGO = "_icons/icon-192.png"; + private JavaScriptBuildHints() { } @@ -126,6 +139,19 @@ static List translatorOptions(BuildRequest request) { if (darkReader != null) { out.add("-Dcodename1.javascript.darkreaderLock=" + !"false".equalsIgnoreCase(darkReader.trim())); } + for (String hint : SPLASH_HINTS) { + String value = request.getArg(hint, null); + if (value != null && value.trim().length() > 0) { + out.add("-Dcodename1." + hint + "=" + value.trim()); + } + } + // The loading screen shows the application's icon unless the developer named another + // logo (or none). JavaScriptPwa writes the icon to this path after the translator runs, + // from the same request.getIcon(); without an icon it writes a placeholder square, which + // identifies nothing, so then there is no logo. + if (request.getArg("javascript.splash.logo", null) == null && request.getIcon() != null) { + out.add("-Dcodename1.javascript.splash.logo=" + SPLASH_DEFAULT_LOGO); + } return out; } diff --git a/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java index 9f079da8cb1..b7d25274ba5 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java @@ -107,6 +107,29 @@ void translatorGetsTheTitleAndOnlyTheOptOutsThatWereSet() { assertTrue(opts.contains("-Dcodename1.javascript.darkreaderLock=false"), opts.toString()); } + @Test + void splashHintsReachTheTranslatorAndTheIconIsTheDefaultLogo() { + BuildRequest r = request("javascript.splash.appearance", " dark ", + "javascript.splash.darkBackground", "#1e1e1e"); + List opts = JavaScriptBuildHints.translatorOptions(r); + assertTrue(opts.contains("-Dcodename1.javascript.splash.appearance=dark"), opts.toString()); + assertTrue(opts.contains("-Dcodename1.javascript.splash.darkBackground=#1e1e1e"), opts.toString()); + // No icon: the PWA step writes a placeholder square, which is not worth showing. + assertFalse(opts.toString().contains("splash.logo"), opts.toString()); + + r.setIcon(new byte[] {1}); + opts = JavaScriptBuildHints.translatorOptions(r); + assertTrue(opts.contains("-Dcodename1.javascript.splash.logo=" + JavaScriptBuildHints.SPLASH_DEFAULT_LOGO), + opts.toString()); + + r = request("javascript.splash.logo", "none"); + r.setIcon(new byte[] {1}); + opts = JavaScriptBuildHints.translatorOptions(r); + assertTrue(opts.contains("-Dcodename1.javascript.splash.logo=none"), opts.toString()); + assertFalse(opts.contains("-Dcodename1.javascript.splash.logo=" + JavaScriptBuildHints.SPLASH_DEFAULT_LOGO), + opts.toString()); + } + @Test void noHintsShipsOnlyTheLegacyPair() { assertEquals(set("iOS7Theme", "android_holo_light"), ship(request())); diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java index 1a44124a328..ec2bd6db845 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java @@ -111,6 +111,78 @@ static void register(List h) { + "lock in its default Dynamic mode only; its Filter, Filter+ and Static modes " + "ignore it. Set to `false` to omit the tag.")); + h.add(new Hint("javascript.splash.appearance") + .group(HintGroup.JAVASCRIPT) + .values("SplashAppearance", "auto", "light", "dark") + .def("auto") + .platform("javascript") + .doc("Which colours the loading screen uses. `auto` (the default) follows the " + + "browser's light/dark preference, or the choice the app remembered with " + + "`Display.setProperty(\"platformHint.javascript.splashAppearance\", \"dark\")`. " + + "`light` and `dark` always use that set, for apps with one appearance.")); + + h.add(new Hint("javascript.splash.background") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .def("#ffffff") + .platform("javascript") + .doc("The loading screen's background in light appearance, as a hex colour (`#rgb`, `#rrggbb`, " + + "with optional alpha) or a CSS colour name. Applied from the first frame the " + + "page paints until the first form shows.")); + + h.add(new Hint("javascript.splash.textColor") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .def("#666666") + .platform("javascript") + .doc("The loading screen's text colour in light appearance, as a hex colour (`#rgb`, `#rrggbb`, " + + "with optional alpha) or a CSS colour name. Applied from the first frame the " + + "page paints until the first form shows.")); + + h.add(new Hint("javascript.splash.accentColor") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .def("#0072ff") + .platform("javascript") + .doc("The loading screen's progress bar colour in light appearance, as a hex colour (`#rgb`, `#rrggbb`, " + + "with optional alpha) or a CSS colour name. Applied from the first frame the " + + "page paints until the first form shows.")); + + h.add(new Hint("javascript.splash.darkBackground") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .def("#121212") + .platform("javascript") + .doc("The loading screen's background in dark appearance, as a hex colour (`#rgb`, `#rrggbb`, " + + "with optional alpha) or a CSS colour name. Applied from the first frame the " + + "page paints until the first form shows.")); + + h.add(new Hint("javascript.splash.darkTextColor") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .def("#b3b3b3") + .platform("javascript") + .doc("The loading screen's text colour in dark appearance, as a hex colour (`#rgb`, `#rrggbb`, " + + "with optional alpha) or a CSS colour name. Applied from the first frame the " + + "page paints until the first form shows.")); + + h.add(new Hint("javascript.splash.darkAccentColor") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .def("#4da3ff") + .platform("javascript") + .doc("The loading screen's progress bar colour in dark appearance, as a hex colour (`#rgb`, `#rrggbb`, " + + "with optional alpha) or a CSS colour name. Applied from the first frame the " + + "page paints until the first form shows.")); + + h.add(new Hint("javascript.splash.logo") + .group(HintGroup.JAVASCRIPT) + .type(HintType.STRING) + .platform("javascript") + .doc("The image the loading screen shows above the progress bar, as a path relative to " + + "the bundle's `index.html` (letters, digits, `/`, `.`, `-` and `_`). Defaults to the " + + "app icon; `none` shows no image.")); + h.add(new Hint("javascript.desktopTheme") .group(HintGroup.JAVASCRIPT) .type(HintType.STRING) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java index e7e196e75a5..1a3022eac13 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java @@ -1693,6 +1693,7 @@ private static void writeIndex(File outputDirectory) throws IOException { System.getProperty(PAGE_TITLE_PROPERTY), Boolean.parseBoolean(Util.systemProperty(ALLOW_TRANSLATION_PROPERTY, "false")), !"false".equalsIgnoreCase(Util.systemProperty(DARKREADER_LOCK_PROPERTY, "true"))); + index = applySplashSettings(index, SplashSettings.fromSystemProperties()); Util.writeUtf8(new File(outputDirectory, "index.html"), index); } @@ -1741,6 +1742,174 @@ static String applyPageSettings(String index, String title, boolean allowTransla .replace("__APP_TITLE__", escapeHtml(pageTitle)); } + /// Prefix of the translator properties the `javascript.splash.*` build hints become. + static final String SPLASH_PROPERTY_PREFIX = "codename1.javascript.splash."; + + /// Local storage key an application writes, through the + /// `platformHint.javascript.splashAppearance` property, to have the next launch's loading + /// screen follow its own light/dark choice instead of the browser's. `HTML5Implementation` + /// writes it; the inline script [#splashTheme(SplashSettings)] emits reads it before the + /// first paint. + static final String SPLASH_APPEARANCE_STORAGE_KEY = "cn1.splashAppearance"; + + /// The loading screen's look: the logo and the colours for each appearance. Every value is + /// validated here, because each one is written verbatim into the page's CSS or markup. + static final class SplashSettings { + /// `auto` (follow the browser, or the app's remembered choice), `light` or `dark`. + final String appearance; + final String background; + final String text; + final String accent; + final String darkBackground; + final String darkText; + final String darkAccent; + /// A URL relative to the bundle root, or null for no logo. + final String logo; + + SplashSettings(String appearance, String background, String text, String accent, + String darkBackground, String darkText, String darkAccent, String logo) { + this.appearance = appearance(appearance); + this.background = color("background", background, "#ffffff"); + this.text = color("textColor", text, "#666666"); + this.accent = color("accentColor", accent, "#0072ff"); + this.darkBackground = color("darkBackground", darkBackground, "#121212"); + this.darkText = color("darkTextColor", darkText, "#b3b3b3"); + this.darkAccent = color("darkAccentColor", darkAccent, "#4da3ff"); + this.logo = logo(logo); + } + + static SplashSettings defaults() { + return new SplashSettings(null, null, null, null, null, null, null, null); + } + + static SplashSettings fromSystemProperties() { + return new SplashSettings(prop("appearance"), prop("background"), prop("textColor"), + prop("accentColor"), prop("darkBackground"), prop("darkTextColor"), + prop("darkAccentColor"), prop("logo")); + } + + private static String prop(String name) { + return System.getProperty(SPLASH_PROPERTY_PREFIX + name); + } + + private static String appearance(String value) { + if (value == null || value.trim().length() == 0) { + return "auto"; + } + String v = value.trim(); + if (v.equalsIgnoreCase("auto") || v.equalsIgnoreCase("light") || v.equalsIgnoreCase("dark")) { + return asciiLower(v); + } + throw new IllegalArgumentException("javascript.splash.appearance must be auto, light or dark, not '" + + value + "'"); + } + + /// A hex colour (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or a CSS colour keyword. Nothing + /// else: `rgb(...)` and friends would need a parser to be sure they cannot close the + /// declaration, and the hex forms express every colour they can. + private static String color(String hint, String value, String def) { + if (value == null || value.trim().length() == 0) { + return def; + } + String v = value.trim(); + boolean ok; + if (v.charAt(0) == '#') { + int n = v.length() - 1; + ok = n == 3 || n == 4 || n == 6 || n == 8; + for (int i = 1; ok && i < v.length(); i++) { + char c = v.charAt(i); + ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + } else { + ok = true; + for (int i = 0; ok && i < v.length(); i++) { + char c = v.charAt(i); + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + } + if (!ok) { + throw new IllegalArgumentException("javascript.splash." + hint + + " must be a hex colour such as #1e1e1e or a CSS colour name, not '" + value + "'"); + } + return v; + } + + /// `none` (or nothing) for no logo; otherwise a path inside the bundle. Restricted to + /// plain path characters so it can go into a `src` attribute as it is, and never `..` + /// or a scheme, so it cannot point the page at another origin. + private static String logo(String value) { + if (value == null || value.trim().length() == 0 || value.trim().equalsIgnoreCase("none")) { + return null; + } + String v = value.trim(); + boolean ok = v.charAt(0) != '/' && v.indexOf("..") < 0; + for (int i = 0; ok && i < v.length(); i++) { + char c = v.charAt(i); + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '/' || c == '.' || c == '-' || c == '_'; + } + if (!ok) { + throw new IllegalArgumentException("javascript.splash.logo must be a path inside the web " + + "bundle (letters, digits, '/', '.', '-', '_'), not '" + value + "'"); + } + return v; + } + } + + private static String asciiLower(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c); + } + return out.toString(); + } + + /// Fills the loading screen's placeholders: its colours and its logo. + /// + /// This adds an inline `"); + } else { + out.append(""); + } + return out.toString(); + } + + private static String vars(String background, String text, String accent) { + return "--cn1-splash-background:" + background + ";--cn1-splash-text:" + text + + ";--cn1-splash-accent:" + accent + ";"; + } + private static String escapeHtml(String text) { StringBuilder out = new StringBuilder(text.length()); for (int i = 0; i < text.length(); i++) { diff --git a/vm/ByteCodeTranslator/src/javascript/index.html b/vm/ByteCodeTranslator/src/javascript/index.html index 2a4b0587370..cda1f49a17e 100644 --- a/vm/ByteCodeTranslator/src/javascript/index.html +++ b/vm/ByteCodeTranslator/src/javascript/index.html @@ -5,26 +5,57 @@ __APP_TITLE__ +